Skip to content

Commit 31e245e

Browse files
akolotovclaude
andcommitted
Address review feedback on client-supplied PRO API key flow
- Centralize the not-configured raise in a new `require_pro_api_key()` helper so every PRO API entry point (`make_blockscout_request`, `make_blockscout_post_request`, `make_metadata_request`, `_fetch_and_process_contract`, `Web3Pool.get`) emits the same error text and names the configurable client header when enabled. - Tighten `_MAX_KEY_LENGTH` from 4096 to 256 (~4x current key length of 79) so the bound rejects abuse without limiting plausible future formats. - Log unexpected ctx-shape failures in `extract_client_pro_api_key_from_ctx` at DEBUG (with `exc_info`) so the defensive bare-except no longer hides bugs silently. - Reword the test-shape comment in `_normalize_key` and document the blanket `@pro_api_key_scope` policy plus the required decorator stacking order (must sit inside `@log_tool_invocation`). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent e86738a commit 31e245e

5 files changed

Lines changed: 80 additions & 35 deletions

File tree

blockscout_mcp_server/pro_api_key_context.py

Lines changed: 64 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,16 +7,29 @@
77
- A normalization/validation helper.
88
- An extractor that reads the key from an MCP context.
99
- A resolver that applies the client-key → server-key precedence rule.
10+
- A ``require_pro_api_key()`` helper that wraps the resolver with the standard
11+
not-configured error so every PRO API entry point raises the same message.
1012
- A @pro_api_key_scope decorator that populates the ContextVar per request.
1113
1214
Kept intentionally separate from tools/decorators.py so authentication and
1315
observability remain decoupled.
16+
17+
Blanket decorator application
18+
-----------------------------
19+
``@pro_api_key_scope`` is applied to *every* MCP tool, including tools that
20+
never call the PRO API (e.g. ``get_chains_list``, ``get_address_by_ens_name``,
21+
``__unlock_blockchain_analysis__``). For those tools the recorded state is
22+
never consulted — a malformed client header is effectively a no-op — but
23+
applying the decorator uniformly means a future contributor cannot accidentally
24+
add a PRO API call to a tool that lacks request-scoped key resolution. Do not
25+
"optimize" by removing it from a tool that today doesn't need it.
1426
"""
1527

1628
from __future__ import annotations
1729

1830
import functools
1931
import inspect
32+
import logging
2033
from collections.abc import Awaitable, Callable
2134
from contextvars import ContextVar
2235
from dataclasses import dataclass
@@ -25,11 +38,15 @@
2538
from blockscout_mcp_server.client_meta import get_header_case_insensitive
2639
from blockscout_mcp_server.config import config
2740

41+
logger = logging.getLogger(__name__)
42+
2843
# ---------------------------------------------------------------------------
2944
# Maximum length accepted for a client-supplied PRO API key value.
30-
# Large enough for any real key or JWT; small enough to reject abuse.
45+
# Blockscout PRO API keys are 79 characters today; 256 leaves ~4x headroom for
46+
# future format changes while still rejecting obvious abuse (multi-KB payloads
47+
# that would only inflate the per-invocation ContextVar / log paths).
3148
# ---------------------------------------------------------------------------
32-
_MAX_KEY_LENGTH = 4096
49+
_MAX_KEY_LENGTH = 256
3350

3451

3552
# ---------------------------------------------------------------------------
@@ -76,7 +93,7 @@ class _Malformed:
7693
def _normalize_key(raw: Any) -> ClientKeyState:
7794
"""Return one of the three states for *raw* header value.
7895
79-
- Non-string → absent (guards against MagicMock in tests).
96+
- Non-string → absent (defensive against unexpected mapping shapes).
8097
- Empty / blank after stripping → absent.
8198
- Contains control characters or exceeds max length → malformed.
8299
- Otherwise → valid with the stripped value.
@@ -136,6 +153,10 @@ def extract_client_pro_api_key_from_ctx(ctx: Any) -> ClientKeyState:
136153
return _normalize_key(raw)
137154

138155
except Exception:
156+
# Defensive: an unexpected ctx shape (e.g. after an MCP transport
157+
# upgrade) must never break the auth path. Log at DEBUG so the bug is
158+
# discoverable without breaking the request.
159+
logger.debug("Unexpected error extracting client PRO API key from ctx", exc_info=True)
139160
return _ABSENT
140161

141162

@@ -171,7 +192,32 @@ def resolve_pro_api_key() -> str:
171192

172193

173194
# ---------------------------------------------------------------------------
174-
# 6. Decorator — populates the ContextVar for the duration of a tool call
195+
# 6. require_pro_api_key — single chokepoint for the "not configured" error
196+
# ---------------------------------------------------------------------------
197+
198+
199+
def require_pro_api_key(disabled_feature: str) -> str:
200+
"""Return the effective PRO API key or raise the standard not-configured error.
201+
202+
Propagates ``ValueError`` from :func:`resolve_pro_api_key` for a malformed
203+
client key. When both the client key and the server key are absent, raises
204+
a ``ValueError`` whose message names ``BLOCKSCOUT_PRO_API_KEY`` and — when
205+
client-supplied keys are enabled — the configured request header. Callers
206+
pass a short ``disabled_feature`` label ("data access", "address metadata",
207+
"contract reads via the PRO API gateway") so the caller's context survives
208+
without each call site duplicating the full sentence.
209+
"""
210+
key = resolve_pro_api_key()
211+
if not key:
212+
hint = "set BLOCKSCOUT_PRO_API_KEY"
213+
if config.pro_api_key_header:
214+
hint = f"set BLOCKSCOUT_PRO_API_KEY on the server, or send the {config.pro_api_key_header} request header"
215+
raise ValueError(f"Blockscout PRO API key is not configured ({hint}); {disabled_feature} is disabled.")
216+
return key
217+
218+
219+
# ---------------------------------------------------------------------------
220+
# 7. Decorator — populates the ContextVar for the duration of a tool call
175221
# ---------------------------------------------------------------------------
176222

177223

@@ -187,6 +233,20 @@ def pro_api_key_scope(func: Callable[..., Awaitable[Any]]) -> Callable[..., Awai
187233
188234
Uses ``functools.wraps`` to preserve the wrapped function's signature so
189235
FastMCP schema generation and REST parameter binding continue to work.
236+
237+
Stacking order
238+
--------------
239+
Apply this decorator *inside* (closer to the function than)
240+
``@log_tool_invocation`` — that is::
241+
242+
@log_tool_invocation
243+
@pro_api_key_scope
244+
async def my_tool(...): ...
245+
246+
Consequence: ``log_tool_invocation`` (and the analytics call it makes) runs
247+
*outside* this scope and therefore cannot read the ContextVar. Analytics
248+
must continue to derive any client-supplied-key signal from ``ctx`` headers
249+
directly, never from ``_client_key_state``.
190250
"""
191251
sig = inspect.signature(func)
192252

blockscout_mcp_server/tools/common.py

Lines changed: 4 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
SERVER_VERSION,
1919
)
2020
from blockscout_mcp_server.models import NextCallInfo, PaginationInfo, ToolResponse
21-
from blockscout_mcp_server.pro_api_key_context import resolve_pro_api_key
21+
from blockscout_mcp_server.pro_api_key_context import require_pro_api_key, resolve_pro_api_key
2222

2323
logger = logging.getLogger(__name__)
2424

@@ -196,10 +196,7 @@ async def make_blockscout_request(
196196
network conditions. Centralizing minimal retries here improves robustness
197197
for all tools and REST endpoints without masking persistent API errors.
198198
"""
199-
if not resolve_pro_api_key():
200-
raise ValueError(
201-
"Blockscout PRO API key is not configured (set BLOCKSCOUT_PRO_API_KEY); data access is disabled."
202-
)
199+
require_pro_api_key("data access")
203200
# Validate per request: cheap on a warm cache; keeps this helper the one chokepoint no caller can bypass.
204201
await ensure_chain_supported(chain_id)
205202
base_url = f"{config.pro_api_base_url}/{chain_id}"
@@ -239,10 +236,7 @@ async def make_blockscout_post_request(
239236
retries occur only for connection-establishment failures (ConnectError,
240237
ConnectTimeout), where the request body is known not to have reached upstream.
241238
"""
242-
if not resolve_pro_api_key():
243-
raise ValueError(
244-
"Blockscout PRO API key is not configured (set BLOCKSCOUT_PRO_API_KEY); data access is disabled."
245-
)
239+
require_pro_api_key("data access")
246240
# Validate per request: cheap on a warm cache; keeps this helper the one chokepoint no caller can bypass.
247241
await ensure_chain_supported(chain_id)
248242
base_url = f"{config.pro_api_base_url}/{chain_id}"
@@ -407,10 +401,7 @@ async def make_metadata_request(api_path: str, params: dict | None = None) -> di
407401
httpx.RequestError: For transport-level errors surfaced after the final
408402
retry
409403
"""
410-
if not resolve_pro_api_key():
411-
raise ValueError(
412-
"Blockscout PRO API key is not configured (set BLOCKSCOUT_PRO_API_KEY); address metadata is disabled."
413-
)
404+
require_pro_api_key("address metadata")
414405
return await _make_blockscout_http_request(
415406
method="GET",
416407
base_url=config.pro_api_base_url,

blockscout_mcp_server/tools/contract/_shared.py

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33

44
from blockscout_mcp_server.cache import CachedContract, contract_cache
55
from blockscout_mcp_server.config import config
6-
from blockscout_mcp_server.pro_api_key_context import resolve_pro_api_key
6+
from blockscout_mcp_server.pro_api_key_context import require_pro_api_key
77
from blockscout_mcp_server.tools.common import (
88
_truncate_constructor_args,
99
make_blockscout_request,
@@ -27,11 +27,9 @@ async def _fetch_and_process_contract(chain_id: str, address: str) -> CachedCont
2727

2828
# Gate on the effective PRO API key before the cache lookup so that a
2929
# malformed or absent key fails closed even when protected data is cached.
30-
# resolve_pro_api_key() propagates ValueError for a malformed client key.
31-
if not resolve_pro_api_key():
32-
raise ValueError(
33-
"Blockscout PRO API key is not configured (set BLOCKSCOUT_PRO_API_KEY); data access is disabled."
34-
)
30+
# require_pro_api_key() propagates ValueError for a malformed client key
31+
# and raises the standard not-configured error when both keys are absent.
32+
require_pro_api_key("data access")
3533

3634
normalized_address = address.lower()
3735
cache_key = f"{chain_id}:{normalized_address}"

blockscout_mcp_server/web3_pool.py

Lines changed: 5 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@
3737

3838
from blockscout_mcp_server.config import config
3939
from blockscout_mcp_server.constants import SERVER_VERSION
40-
from blockscout_mcp_server.pro_api_key_context import resolve_pro_api_key
40+
from blockscout_mcp_server.pro_api_key_context import require_pro_api_key, resolve_pro_api_key
4141
from blockscout_mcp_server.tools.common import ensure_chain_supported
4242

4343

@@ -184,14 +184,10 @@ async def _get_session(self) -> aiohttp.ClientSession:
184184
async def get(self, chain_id: str, headers: dict[str, str] | None = None) -> AsyncWeb3:
185185
# Fail fast when no effective PRO API key is available — no network call
186186
# should be made when the gateway is guaranteed to reject the request.
187-
# resolve_pro_api_key() raises ValueError for a malformed client key;
188-
# we raise ValueError (not-configured) when the resolved key is empty.
189-
resolved_key = resolve_pro_api_key()
190-
if not resolved_key:
191-
raise ValueError(
192-
"Blockscout PRO API key is not configured (set BLOCKSCOUT_PRO_API_KEY); "
193-
"contract reads via the PRO API gateway are disabled."
194-
)
187+
# require_pro_api_key() propagates ValueError for a malformed client
188+
# key and raises the standard not-configured error when both keys are
189+
# absent.
190+
require_pro_api_key("contract reads via the PRO API gateway")
195191

196192
# Validate the chain before constructing anything.
197193
await ensure_chain_supported(chain_id)

tests/test_pro_api_key_context.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -94,12 +94,12 @@ def test_normalize_control_char_del_is_malformed():
9494

9595

9696
def test_normalize_over_length_is_malformed():
97-
state = _normalize_key("a" * 4097)
97+
state = _normalize_key("a" * 257)
9898
assert isinstance(state, _Malformed)
9999

100100

101101
def test_normalize_exactly_max_length_is_valid():
102-
state = _normalize_key("a" * 4096)
102+
state = _normalize_key("a" * 256)
103103
assert isinstance(state, _Valid)
104104

105105

@@ -229,7 +229,7 @@ def test_malformed_error_does_not_embed_control_char_value(monkeypatch):
229229
def test_malformed_error_does_not_embed_over_length_value(monkeypatch):
230230
"""ValueError message must not reproduce an over-length submitted value."""
231231
monkeypatch.setattr(config, "pro_api_key", "server-key", raising=False)
232-
raw_value = "a" * 4097
232+
raw_value = "a" * 257
233233
with _set_key_state(_Malformed()):
234234
with pytest.raises(ValueError) as exc_info:
235235
resolve_pro_api_key()

0 commit comments

Comments
 (0)