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
1214Kept intentionally separate from tools/decorators.py so authentication and
1315observability 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
1628from __future__ import annotations
1729
1830import functools
1931import inspect
32+ import logging
2033from collections .abc import Awaitable , Callable
2134from contextvars import ContextVar
2235from dataclasses import dataclass
2538from blockscout_mcp_server .client_meta import get_header_case_insensitive
2639from 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:
7693def _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
0 commit comments