Skip to content

Commit d88103c

Browse files
authored
feat: support allowlisted intermediary header (#203)
1 parent 82f6f70 commit d88103c

File tree

10 files changed

+237
-5
lines changed

10 files changed

+237
-5
lines changed

.env.example

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,3 +41,7 @@ BLOCKSCOUT_MCP_USER_AGENT="Blockscout MCP"
4141
BLOCKSCOUT_MIXPANEL_TOKEN=""
4242
BLOCKSCOUT_MIXPANEL_API_HOST=""
4343

44+
# Annotate MCP client name with an allowlisted intermediary when running in HTTP mode.
45+
BLOCKSCOUT_INTERMEDIARY_HEADER="Blockscout-MCP-Intermediary"
46+
BLOCKSCOUT_INTERMEDIARY_ALLOWLIST="ClaudeDesktop,HigressPlugin"
47+

Dockerfile

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,5 +34,7 @@ ENV BLOCKSCOUT_RPC_POOL_PER_HOST="50"
3434
ENV BLOCKSCOUT_MCP_USER_AGENT="Blockscout MCP"
3535
# ENV BLOCKSCOUT_MIXPANEL_TOKEN="" # Intentionally commented out: pass at runtime to avoid embedding secrets in image
3636
ENV BLOCKSCOUT_MIXPANEL_API_HOST=""
37+
ENV BLOCKSCOUT_INTERMEDIARY_HEADER="Blockscout-MCP-Intermediary"
38+
ENV BLOCKSCOUT_INTERMEDIARY_ALLOWLIST="ClaudeDesktop,HigressPlugin"
3739

3840
CMD ["python", "-m", "blockscout_mcp_server"]

SPEC.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -497,6 +497,8 @@ If the client name cannot be determined from the MCP session parameters, the ser
497497

498498
This provides a clear audit trail, helping to diagnose issues that may be specific to certain client versions or protocol implementations. For stateless calls, such as those from the REST API where no client is present, this information is gracefully omitted.
499499

500+
In HTTP streamable mode, an allowlisted intermediary identifier can annotate the client name. The header name is configured via `BLOCKSCOUT_INTERMEDIARY_HEADER` (default: `Blockscout-MCP-Intermediary`) and allowed values via `BLOCKSCOUT_INTERMEDIARY_ALLOWLIST` (default: `ClaudeDesktop,HigressPlugin`). After trimming, collapsing whitespace, and validating length (≤16), the intermediary is appended to the base client name as `base/variant`. Invalid or disallowed values are ignored.
501+
500502
#### 3. Mixpanel Analytics for Tool Invocation
501503

502504
To gain insight into tool usage patterns, the server can optionally report tool invocations to Mixpanel.
@@ -510,7 +512,7 @@ To gain insight into tool usage patterns, the server can optionally report tool
510512

511513
- Tracked properties (per event):
512514
- Client IP address derived from the HTTP request, preferring proxy headers when present: `X-Forwarded-For` (first value), then `X-Real-IP`, otherwise connection `client.host`.
513-
- MCP client name (or the HTTP `User-Agent` when the client name is unavailable).
515+
- MCP client name (or the HTTP `User-Agent` when the client name is unavailable). When a valid intermediary header is present, the client name is recorded as `base/variant`.
514516
- MCP client version.
515517
- MCP protocol version.
516518
- Tool arguments (currently sent as-is, without truncation).

blockscout_mcp_server/client_meta.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
from dataclasses import dataclass
66
from typing import Any
77

8+
from blockscout_mcp_server.config import config
9+
810
UNDEFINED_CLIENT_NAME = "N/A"
911
UNDEFINED_CLIENT_VERSION = "N/A"
1012
UNKNOWN_PROTOCOL_VERSION = "Unknown"
@@ -40,10 +42,39 @@ def get_header_case_insensitive(headers: Any, key: str, default: str = "") -> st
4042
return default
4143

4244

45+
def _parse_intermediary_header(value: str, allowlist_raw: str) -> str:
46+
"""Normalize and validate an intermediary header value.
47+
48+
Extracts the first non-empty entry from a comma-separated list, normalizes whitespace,
49+
guards against invalid characters or length, and ensures the value is allowlisted.
50+
Returns the normalized value if valid, otherwise an empty string.
51+
"""
52+
if not value:
53+
return ""
54+
first_value = next(
55+
(stripped for v in value.split(",") if (stripped := v.strip())),
56+
"",
57+
)
58+
if not first_value:
59+
return ""
60+
normalized = " ".join(first_value.split())
61+
if len(normalized) > 16:
62+
return ""
63+
if "/" in normalized:
64+
return ""
65+
if any(ord(c) < 32 or ord(c) == 127 for c in normalized):
66+
return ""
67+
allowlist = [stripped.lower() for v in allowlist_raw.split(",") if (stripped := v.strip())]
68+
if normalized.lower() not in allowlist:
69+
return ""
70+
return normalized
71+
72+
4373
def extract_client_meta_from_ctx(ctx: Any) -> ClientMeta:
4474
"""Extract client meta (name, version, protocol, user_agent) from an MCP Context.
4575
4676
- name: MCP client name. If unavailable, defaults to "N/A" constant or falls back to user agent.
77+
If an intermediary HTTP header is present, it is appended to the client name.
4778
- version: MCP client version. If unavailable, defaults to "N/A" constant.
4879
- protocol: MCP protocol version. If unavailable, defaults to "Unknown" constant.
4980
- user_agent: Extracted from HTTP request headers if available.
@@ -52,6 +83,7 @@ def extract_client_meta_from_ctx(ctx: Any) -> ClientMeta:
5283
client_version = UNDEFINED_CLIENT_VERSION
5384
protocol: str = UNKNOWN_PROTOCOL_VERSION
5485
user_agent: str = ""
86+
intermediary: str = ""
5587

5688
try:
5789
client_params = getattr(getattr(ctx, "session", None), "client_params", None)
@@ -70,9 +102,16 @@ def extract_client_meta_from_ctx(ctx: Any) -> ClientMeta:
70102
if request is not None:
71103
headers = request.headers or {}
72104
user_agent = get_header_case_insensitive(headers, "user-agent", "")
105+
header_name = config.intermediary_header
106+
allowlist_raw = config.intermediary_allowlist
107+
if header_name and allowlist_raw:
108+
intermediary_raw = get_header_case_insensitive(headers, header_name, "")
109+
intermediary = _parse_intermediary_header(intermediary_raw, allowlist_raw)
73110
# If client name is still undefined, fallback to User-Agent
74111
if client_name == UNDEFINED_CLIENT_NAME and user_agent:
75112
client_name = user_agent
113+
if intermediary:
114+
client_name = f"{client_name}/{intermediary}"
76115
except Exception: # pragma: no cover - tolerate any ctx shape
77116
pass
78117

blockscout_mcp_server/config.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,5 +39,9 @@ class ServerConfig(BaseSettings):
3939
mixpanel_token: str = ""
4040
mixpanel_api_host: str = "" # Optional custom API host (e.g., EU region)
4141

42+
# Composite client name configuration
43+
intermediary_header: str = "Blockscout-MCP-Intermediary"
44+
intermediary_allowlist: str = "ClaudeDesktop,HigressPlugin"
45+
4246

4347
config = ServerConfig()

dxt/manifest-dev.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"dxt_version": "0.1",
33
"name": "blockscout-mcp-dev",
44
"display_name": "Blockscout (dev)",
5-
"version": "0.2.0",
5+
"version": "0.3.0",
66
"description": "Contextual blockchain activity analysis via Blockscout APIs",
77
"long_description": "This extension enables contextual blockchain activity analysis with multi-chain support, intelligent context optimization, smart response slicing, and seamless pagination. The server exposes blockchain data including balances, tokens, NFTs, contract metadata, transactions, and logs via MCP for comprehensive blockchain analysis. This extension acts as a proxy to the official Blockscout MCP server.",
88
"author": {
@@ -26,7 +26,8 @@
2626
"${__dirname}/node_modules/mcp-remote/dist/proxy.js",
2727
"${user_config.blockscout_url}",
2828
"--transport", "http-only",
29-
"--allow-http"
29+
"--allow-http",
30+
"--header", "Blockscout-MCP-Intermediary: ClaudeDesktop"
3031
],
3132
"env": {}
3233
}

dxt/manifest.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"dxt_version": "0.1",
33
"name": "blockscout-mcp",
44
"display_name": "Blockscout",
5-
"version": "0.2.0",
5+
"version": "0.3.0",
66
"description": "Contextual blockchain activity analysis via Blockscout APIs",
77
"long_description": "This extension enables contextual blockchain activity analysis with multi-chain support, intelligent context optimization, smart response slicing, and seamless pagination. The server exposes blockchain data including balances, tokens, NFTs, contract metadata, transactions, and logs via MCP for comprehensive blockchain analysis. This extension acts as a proxy to the official Blockscout MCP server.",
88
"author": {
@@ -25,7 +25,8 @@
2525
"args": [
2626
"${__dirname}/node_modules/mcp-remote/dist/proxy.js",
2727
"https://mcp.blockscout.com/mcp/",
28-
"--transport", "http-only"
28+
"--transport", "http-only",
29+
"--header", "Blockscout-MCP-Intermediary: ClaudeDesktop"
2930
],
3031
"env": {}
3132
}

tests/test_analytics.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,3 +72,71 @@ def test_tracks_with_headers(monkeypatch):
7272
assert args[2]["tool_args"] == {"x": 2}
7373
assert args[2]["protocol_version"] == "2024-11-05"
7474
assert kwargs.get("meta") == {"ip": "203.0.113.5"}
75+
76+
77+
def test_tracks_with_intermediary_header(monkeypatch):
78+
monkeypatch.setattr(server_config, "mixpanel_token", "test-token", raising=False)
79+
headers = {
80+
"x-forwarded-for": "203.0.113.5",
81+
"user-agent": "pytest-UA",
82+
"Blockscout-MCP-Intermediary": "ClaudeDesktop",
83+
}
84+
req = DummyRequest(headers=headers)
85+
ctx = DummyCtx(request=req, client_name="node", client_version="1.0.0")
86+
with patch("blockscout_mcp_server.analytics.Mixpanel") as mp_cls:
87+
mp_instance = MagicMock()
88+
mp_cls.return_value = mp_instance
89+
analytics.set_http_mode(True)
90+
analytics.track_tool_invocation(ctx, "tool_name", {"x": 2})
91+
args, _ = mp_instance.track.call_args
92+
assert args[2]["client_name"] == "node/ClaudeDesktop"
93+
94+
95+
def test_tracks_with_invalid_intermediary(monkeypatch):
96+
monkeypatch.setattr(server_config, "mixpanel_token", "test-token", raising=False)
97+
headers = {
98+
"x-forwarded-for": "203.0.113.5",
99+
"user-agent": "pytest-UA",
100+
"Blockscout-MCP-Intermediary": "Unknown",
101+
}
102+
req = DummyRequest(headers=headers)
103+
ctx = DummyCtx(request=req, client_name="node", client_version="1.0.0")
104+
with patch("blockscout_mcp_server.analytics.Mixpanel") as mp_cls:
105+
mp_instance = MagicMock()
106+
mp_cls.return_value = mp_instance
107+
analytics.set_http_mode(True)
108+
analytics.track_tool_invocation(ctx, "tool_name", {"x": 2})
109+
args, _ = mp_instance.track.call_args
110+
assert args[2]["client_name"] == "node"
111+
112+
113+
def test_tracks_with_intermediary_and_user_agent_fallback(monkeypatch):
114+
monkeypatch.setattr(server_config, "mixpanel_token", "test-token", raising=False)
115+
headers = {
116+
"x-forwarded-for": "203.0.113.5",
117+
"user-agent": "pytest-UA",
118+
"Blockscout-MCP-Intermediary": "HigressPlugin",
119+
}
120+
req = DummyRequest(headers=headers)
121+
ctx = DummyCtx(request=req, client_name="", client_version="")
122+
with patch("blockscout_mcp_server.analytics.Mixpanel") as mp_cls:
123+
mp_instance = MagicMock()
124+
mp_cls.return_value = mp_instance
125+
analytics.set_http_mode(True)
126+
analytics.track_tool_invocation(ctx, "tool_name", {"x": 2})
127+
args, _ = mp_instance.track.call_args
128+
assert args[2]["client_name"] == "pytest-UA/HigressPlugin"
129+
130+
131+
def test_tracks_with_intermediary_no_client_or_user_agent(monkeypatch):
132+
monkeypatch.setattr(server_config, "mixpanel_token", "test-token", raising=False)
133+
headers = {"Blockscout-MCP-Intermediary": "ClaudeDesktop"}
134+
req = DummyRequest(headers=headers)
135+
ctx = DummyCtx(request=req, client_name="", client_version="")
136+
with patch("blockscout_mcp_server.analytics.Mixpanel") as mp_cls:
137+
mp_instance = MagicMock()
138+
mp_cls.return_value = mp_instance
139+
analytics.set_http_mode(True)
140+
analytics.track_tool_invocation(ctx, "tool_name", {"x": 2})
141+
args, _ = mp_instance.track.call_args
142+
assert args[2]["client_name"] == "N/A/ClaudeDesktop"

tests/test_client_meta.py

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,11 @@
44
UNDEFINED_CLIENT_NAME,
55
UNDEFINED_CLIENT_VERSION,
66
UNKNOWN_PROTOCOL_VERSION,
7+
_parse_intermediary_header,
78
extract_client_meta_from_ctx,
89
get_header_case_insensitive,
910
)
11+
from blockscout_mcp_server.config import config
1012

1113

1214
def test_extract_client_meta_full():
@@ -57,3 +59,88 @@ def test_get_header_case_insensitive_with_dict():
5759
assert get_header_case_insensitive(headers, "USER-AGENT") == "ua-test/1.0"
5860
assert get_header_case_insensitive(headers, "x-real-ip") == "1.2.3.4"
5961
assert get_header_case_insensitive(headers, "missing", "default") == "default"
62+
63+
64+
def _ctx_with_intermediary(value: str) -> SimpleNamespace:
65+
headers = {"Blockscout-MCP-Intermediary": value}
66+
request = SimpleNamespace(headers=headers)
67+
client_info = SimpleNamespace(name="node", version="1.0.0")
68+
client_params = SimpleNamespace(clientInfo=client_info, protocolVersion="2024-11-05")
69+
return SimpleNamespace(
70+
session=SimpleNamespace(client_params=client_params),
71+
request_context=SimpleNamespace(request=request),
72+
)
73+
74+
75+
def test_intermediary_header_merged(monkeypatch):
76+
monkeypatch.setattr(
77+
config,
78+
"intermediary_allowlist",
79+
"ClaudeDesktop,HigressPlugin",
80+
raising=False,
81+
)
82+
ctx = _ctx_with_intermediary(" claudeDESKTOP ")
83+
meta = extract_client_meta_from_ctx(ctx)
84+
assert meta.name == "node/claudeDESKTOP"
85+
86+
87+
def test_intermediary_header_uses_user_agent_when_client_missing(monkeypatch):
88+
monkeypatch.setattr(
89+
config,
90+
"intermediary_allowlist",
91+
"ClaudeDesktop,HigressPlugin",
92+
raising=False,
93+
)
94+
headers = {
95+
"User-Agent": "ua-test/9.9.9",
96+
"Blockscout-MCP-Intermediary": "HigressPlugin",
97+
}
98+
request = SimpleNamespace(headers=headers)
99+
ctx = SimpleNamespace(request_context=SimpleNamespace(request=request))
100+
101+
meta = extract_client_meta_from_ctx(ctx)
102+
assert meta.name == "ua-test/9.9.9/HigressPlugin"
103+
104+
105+
def test_intermediary_header_appends_when_no_client_and_no_user_agent(monkeypatch):
106+
monkeypatch.setattr(
107+
config,
108+
"intermediary_allowlist",
109+
"ClaudeDesktop,HigressPlugin",
110+
raising=False,
111+
)
112+
ctx = _ctx_with_intermediary("HigressPlugin")
113+
ctx.session.client_params.clientInfo.name = "" # type: ignore[attr-defined]
114+
ctx.session.client_params.clientInfo.version = "" # type: ignore[attr-defined]
115+
ctx.session.client_params.protocolVersion = None # type: ignore[attr-defined]
116+
ctx.request_context.request.headers.pop("Blockscout-MCP-Intermediary", None)
117+
ctx.request_context.request.headers["Blockscout-MCP-Intermediary"] = "HigressPlugin"
118+
ctx.request_context.request.headers.pop("User-Agent", None)
119+
120+
meta = extract_client_meta_from_ctx(ctx)
121+
assert meta.name == "N/A/HigressPlugin"
122+
123+
124+
def test_parse_intermediary_header_allowlisted():
125+
allowlist = "ClaudeDesktop,HigressPlugin"
126+
assert _parse_intermediary_header(" claudeDESKTOP ", allowlist) == "claudeDESKTOP"
127+
128+
129+
def test_parse_intermediary_header_not_allowlisted():
130+
allowlist = "ClaudeDesktop,HigressPlugin"
131+
assert _parse_intermediary_header("Unknown", allowlist) == ""
132+
133+
134+
def test_parse_intermediary_header_invalid_char():
135+
allowlist = "BadValue"
136+
assert _parse_intermediary_header("Bad/Value", allowlist) == ""
137+
138+
139+
def test_parse_intermediary_header_too_long():
140+
allowlist = "X" * 17
141+
assert _parse_intermediary_header("X" * 17, allowlist) == ""
142+
143+
144+
def test_parse_intermediary_header_multiple_values():
145+
allowlist = "ClaudeDesktop,HigressPlugin"
146+
assert _parse_intermediary_header(" ,HigressPlugin,Other", allowlist) == "HigressPlugin"

tests/tools/test_decorators.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import logging
2+
from types import SimpleNamespace
23
from unittest.mock import MagicMock
34

45
import mcp.types as types
@@ -77,3 +78,26 @@ async def dummy_tool(a: int, ctx: Context) -> int:
7778
assert "Tool invoked: dummy_tool" in log_text
7879
assert "with args: {'a': 1}" in log_text
7980
assert "(Client: test-client, Version: 1.2.3, Protocol: 2024-11-05)" in log_text
81+
82+
83+
@pytest.mark.asyncio
84+
async def test_log_tool_invocation_with_intermediary(caplog: pytest.LogCaptureFixture, mock_ctx: Context) -> None:
85+
caplog.set_level(logging.INFO, logger="blockscout_mcp_server.tools.decorators")
86+
87+
@log_tool_invocation
88+
async def dummy_tool(a: int, ctx: Context) -> int:
89+
return a
90+
91+
headers = {"Blockscout-MCP-Intermediary": "HigressPlugin"}
92+
mock_ctx.request_context = SimpleNamespace(request=SimpleNamespace(headers=headers))
93+
mock_ctx.session = MagicMock()
94+
mock_ctx.session.client_params = types.InitializeRequestParams(
95+
protocolVersion="2024-11-05",
96+
capabilities=types.ClientCapabilities(),
97+
clientInfo=types.Implementation(name="test-client", version="1.2.3"),
98+
)
99+
100+
await dummy_tool(1, ctx=mock_ctx)
101+
102+
log_text = caplog.text
103+
assert "(Client: test-client/HigressPlugin, Version: 1.2.3, Protocol: 2024-11-05)" in log_text

0 commit comments

Comments
 (0)