diff --git a/.env.example b/.env.example index d3051fe2..cb811a38 100644 --- a/.env.example +++ b/.env.example @@ -28,6 +28,10 @@ BLOCKSCOUT_PRO_API_CONFIG_TTL_SECONDS=300 # * read_contract — eth_call is routed through the PRO API JSON-RPC gateway, which requires the key; when unset, read_contract fails fast and makes no network call. # Generate one at https://dev.blockscout.com. BLOCKSCOUT_PRO_API_KEY="" +# Name of the request header an MCP client uses to supply its own PRO API key (MCP tools over HTTP only). +# When a client sends this header, its key takes precedence over BLOCKSCOUT_PRO_API_KEY for that request; absent it, the server key is used. +# A malformed client-supplied key fails any PRO API request that would use it, with no fallback; tools that don't use the PRO API are unaffected. Set to an empty string to disable client-supplied keys entirely. +BLOCKSCOUT_PRO_API_KEY_HEADER="Blockscout-MCP-Pro-Api-Key" BLOCKSCOUT_CHAINS_LIST_TTL_SECONDS=300 BLOCKSCOUT_PROGRESS_INTERVAL_SECONDS="15.0" diff --git a/AGENTS.md b/AGENTS.md index b9bc9d65..e4af956a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,6 +23,7 @@ mcp-server/ │ ├── analytics.py # Centralized Mixpanel analytics for tool invocations (HTTP mode only) │ ├── telemetry.py # Fire-and-forget community telemetry reporting │ ├── client_meta.py # Shared client metadata extraction helpers and defaults +│ ├── pro_api_key_context.py # Request-scoped client-supplied PRO API key state, resolver, and @pro_api_key_scope decorator │ ├── cache.py # Simple in-memory cache for chain data │ ├── web3_pool.py # Async Web3 connection pool manager │ ├── models.py # Defines standardized Pydantic models for all tool responses @@ -131,6 +132,7 @@ mcp-server/ │ ├── test_analytics_source.py # Unit tests for analytics source detection │ ├── test_cache.py # Unit tests for cache behavior │ ├── test_client_meta.py # Unit tests for client metadata extraction +│ ├── test_pro_api_key_context.py # Unit tests for client-supplied PRO API key resolution │ ├── test_hatch_build.py # Unit tests for custom Hatch build hook helpers │ ├── test_instructions_data.py # Unit tests for the InstructionsData payload model │ ├── test_integration_helpers.py # Unit tests for integration test helpers @@ -360,6 +362,7 @@ mcp-server/ * Provides a singleton configuration object that can be imported and used by other modules, especially by `tools/common.py` for API calls. * `mcp_allowed_hosts: str`: Comma-separated list of allowed `Host` header values for DNS rebinding protection (default: empty, auto-detected based on bind host). * `mcp_allowed_origins: str`: Comma-separated list of allowed `Origin` header values for DNS rebinding protection (default: empty, auto-detected based on bind host). + * `pro_api_key_header: str`: Name of the request header an MCP client uses to supply its own Blockscout PRO API key (default: `Blockscout-MCP-Pro-Api-Key`; empty string disables the feature). * **`constants.py`**: * Defines centralized constants used throughout the application, including data truncation limits. * Ensures consistency between different parts of the application. @@ -381,6 +384,10 @@ mcp-server/ * Provides `ClientMeta` dataclass and `extract_client_meta_from_ctx()` function. * Falls back to User-Agent header when MCP client name is unavailable. * Ensures consistent sentinel defaults ("N/A", "Unknown") across logging and analytics modules. + * **`pro_api_key_context.py`**: + * Owns request-scoped resolution of a client-supplied Blockscout PRO API key, kept separate from logging/observability. + * Provides a `ContextVar` of the per-request client-key state, a normalization/validation helper, `extract_client_pro_api_key_from_ctx()`, `resolve_pro_api_key()` (precedence: valid client key → server key → not-configured error; malformed client key → terminal error, no fallback), and the `@pro_api_key_scope` decorator. + * Honored only for genuine MCP calls (ignored when `ctx.call_source == "rest"`); the key is never logged or placed in cache keys. * **`cache.py`**: * Encapsulates in-memory caching of chain data with TTL management. * **`web3_pool.py`**: diff --git a/Dockerfile b/Dockerfile index 2ac238d9..6812190f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -49,6 +49,7 @@ ENV BLOCKSCOUT_MIXPANEL_API_HOST="" ENV BLOCKSCOUT_DISABLE_COMMUNITY_TELEMETRY="false" ENV BLOCKSCOUT_INTERMEDIARY_HEADER="Blockscout-MCP-Intermediary" ENV BLOCKSCOUT_INTERMEDIARY_ALLOWLIST="ClaudeDesktop,HigressPlugin,EvaluationSuite" +ENV BLOCKSCOUT_PRO_API_KEY_HEADER="Blockscout-MCP-Pro-Api-Key" # Set the default transport mode. Can be overridden at runtime with -e. # Options: "stdio" (default), "http" diff --git a/README.md b/README.md index 12a8a03a..48546d41 100644 --- a/README.md +++ b/README.md @@ -229,6 +229,8 @@ To obtain one, create an account at https://dev.blockscout.com (the free tier do export BLOCKSCOUT_PRO_API_KEY=proapi_your_key_here ``` +**Client-supplied keys (HTTP MCP only).** When the server runs in HTTP mode, an MCP client can supply its own PRO API key in a request header — by default `Blockscout-MCP-Pro-Api-Key`, configurable via `BLOCKSCOUT_PRO_API_KEY_HEADER` (set it to an empty string to disable client-supplied keys entirely). A client-supplied key takes precedence over `BLOCKSCOUT_PRO_API_KEY` for that request; if the client sends no key, the server falls back to its own configured key; if neither is present, the request fails with the not-configured error. A client key that is present but malformed fails any request that needs the PRO API with no fallback (the server never silently uses its own key in place of a bad client key); tools that don't use the PRO API are unaffected. This makes it possible to run a shared HTTP server where each client authenticates with its own key. The client-key header is honored only for genuine MCP tool calls — the REST API ignores it and always authenticates with the server's configured key. + ### Running the Server The server runs in `stdio` mode by default: diff --git a/SPEC.md b/SPEC.md index 492c48be..167126ae 100644 --- a/SPEC.md +++ b/SPEC.md @@ -193,17 +193,26 @@ This architecture provides the flexibility of a multi-protocol server without th All Blockscout data flows through the authenticated Blockscout PRO API gateway. Authentication and the request identity (`User-Agent`) are centralized here rather than scattered across individual tools. +The key's purpose is to ensure every request the server makes to the Blockscout API is authorized — not to act as an access-control gate on MCP functionality itself. An MCP response is therefore gated only insofar as it requires a fresh, authorized upstream request: when no such request is made (for example, a cache hit), there is nothing to authorize. This principle explains several behaviors documented below, including what does not require the key and why cached data may be served without validating a client-supplied key upstream. + **Credential** - The single credential is the `BLOCKSCOUT_PRO_API_KEY` environment variable (`config.pro_api_key`, empty by default). When set, it is sent as an `Authorization: Bearer ` header on every PRO API request. - The header is built and attached per request inside the request helpers, never configured on a shared HTTP client, so the key is never sent to other upstreams (BENS, Chainscout). A bare `Bearer` token is never emitted: the `Authorization` header is added only when the key is non-empty. +**Client-supplied credential (MCP tools over HTTP only)** + +- In addition to the server-side key, an MCP client may supply its own PRO API key in a dedicated request header whose name is configured by `BLOCKSCOUT_PRO_API_KEY_HEADER` (`config.pro_api_key_header`, default `Blockscout-MCP-Pro-Api-Key`). Setting this config to an empty string disables the feature. +- Resolution is pure precedence with **no fallback on a bad client key**: a valid client-supplied key is used for that request; if the client supplies no key, the server-side key is used; if neither exists, the request fails with the not-configured error. A client key that is present but malformed (control characters, or over the length bound) is a terminal error for any PRO-authenticated request that would consume it — the server never silently falls back to its own key for a malformed client key. Tools that never call the PRO API (for example `get_chains_list` or ENS lookups) are unaffected: the malformed state is recorded for the invocation but only the PRO API request helpers consult it. +- The credential is resolved per request and scoped to a single tool invocation (see `blockscout_mcp_server/pro_api_key_context.py`). The client key is read only for genuine MCP calls (never in REST mode) and is never written to logs, analytics, or cache keys. +- Because the key authorizes upstream requests rather than gating MCP functionality, a response served entirely from cache (e.g. contract metadata/source) requires only that some effective key be present, not that the client-supplied key was validated upstream. A well-formed but invalid, expired, or out-of-credit client key may therefore receive cached PRO-gated data — no protected upstream request is made on its behalf. This is a deliberate consequence of the principle above, not a validation gap. + **Two transports, one scheme** The server reaches the PRO API over two transports, each with its own header builder, but both follow the same `Bearer` scheme: - **REST / data path** (`make_blockscout_request`, `make_blockscout_post_request`, `make_metadata_request`): headers come from `_pro_api_headers()` in `blockscout_mcp_server/tools/common.py` — always `User-Agent` and `Accept: application/json`, plus `Authorization: Bearer ` when a key is configured. -- **JSON-RPC path** for `read_contract` (the Async Web3 Connection Pool): base headers come from `_default_headers()` in `blockscout_mcp_server/web3_pool.py`, and `Authorization` is appended at request time by `_request_headers()`. The auth header is deliberately excluded from the pool's cache keys and resolved on every call (including cache hits), so the secret never enters internal cache dictionaries and the current key is always applied — even to already-pooled providers. +- **JSON-RPC path** for `read_contract` (the Async Web3 Connection Pool): the `Authorization` header is injected per request rather than stored on the shared pooled provider, so the key never enters the pool's cache keys and concurrent requests carrying different client keys cannot cross-contaminate (see `blockscout_mcp_server/web3_pool.py`). **User-Agent** @@ -212,11 +221,13 @@ The server reaches the PRO API over two transports, each with its own header bui **Effect of a missing key** -The key requirement is enforced as a single chokepoint: each PRO API entry point checks `config.pro_api_key` first and raises a `ValueError` *before any network call*, so the server never issues a request the gateway is guaranteed to reject. The chain-support validation runs only after this check, keeping the key as the first gate. +The key requirement is enforced as a single chokepoint: each PRO API entry point resolves the effective key and raises a `ValueError` *before any network call*, so the server never issues a request the gateway is guaranteed to reject. The effective key is the client-supplied key when present and valid, otherwise the server-side key; resolution runs before chain-support validation, keeping the key as the first gate. - **Primary data requests** (`make_blockscout_request` / `make_blockscout_post_request`) and **contract reads** (`Web3Pool.get`) fail fast — the tool returns a clear error and makes no network call. - **Secondary metadata requests** (`make_metadata_request`, used by `get_address_info`) also fail fast, but callers treat this like any other metadata failure: the `metadata` field is returned `null` with an explanatory note while the primary data is still returned. +A malformed client key raises a distinct terminal error (no fallback); only the genuine absence of both a client key and a server key raises the not-configured error. + **What does not require the key** - Chain discovery and validation read the PRO API *config* endpoint (`/api/json/config`) without authentication, so `get_chains_list` and chain-support checks work regardless of the key. Only *data access* is gated. @@ -225,6 +236,7 @@ The key requirement is enforced as a single chokepoint: each PRO API entry point **Extended HTTP / REST mode** - The PRO API key stays server-side config; REST consumers never supply it. A REST client authenticates (if at all) to the MCP server itself, while the server authenticates to the PRO API with its own configured key. +- The client-supplied key header (`BLOCKSCOUT_PRO_API_KEY_HEADER`) is honored only for genuine MCP tool calls; the REST layer continues to ignore any client-supplied key and authenticates to the PRO API solely with the server's configured key. Extending client-supplied keys to REST is deliberately out of scope for this iteration. - An `Authorization` header sent by a REST client is never forwarded to the PRO API. The data path builds PRO API headers solely from server config and does not read incoming request headers, and the Web3 pool explicitly strips any caller-supplied `Authorization` before constructing requests or cache keys. **Error semantics** diff --git a/blockscout_mcp_server/__init__.py b/blockscout_mcp_server/__init__.py index e70bb54a..4aab29f0 100644 --- a/blockscout_mcp_server/__init__.py +++ b/blockscout_mcp_server/__init__.py @@ -1,4 +1,4 @@ # SPDX-License-Identifier: LicenseRef-Blockscout """Blockscout MCP Server package.""" -__version__ = "0.16.0.dev15" +__version__ = "0.16.0.dev16" diff --git a/blockscout_mcp_server/config.py b/blockscout_mcp_server/config.py index 1ada947b..598d4e12 100644 --- a/blockscout_mcp_server/config.py +++ b/blockscout_mcp_server/config.py @@ -22,6 +22,7 @@ class ServerConfig(BaseSettings): pro_api_config_ttl_seconds: int = 300 pro_api_config_refresh_retry_seconds: int = 30 pro_api_key: str = "" + pro_api_key_header: str = "Blockscout-MCP-Pro-Api-Key" @field_validator("pro_api_base_url") @classmethod @@ -33,6 +34,11 @@ def normalize_pro_api_base_url(cls, value: str) -> str: def normalize_pro_api_key(cls, value: str) -> str: return value.strip() + @field_validator("pro_api_key_header") + @classmethod + def normalize_pro_api_key_header(cls, value: str) -> str: + return value.strip() + # Metadata configuration (PRO API metadata endpoint) metadata_timeout: float = 30.0 diff --git a/blockscout_mcp_server/llms.txt b/blockscout_mcp_server/llms.txt index 4f085731..9b3852f5 100644 --- a/blockscout_mcp_server/llms.txt +++ b/blockscout_mcp_server/llms.txt @@ -25,6 +25,8 @@ See the [agent-skills README](https://github.com/blockscout/agent-skills) for fu - **ChatGPT Apps:** Enable the Blockscout app from the [ChatGPT Apps marketplace](https://chatgpt.com/apps?q=Blockscout) - **Cursor and Gemini CLI:** Direct HTTP URL with 180-second timeout +**Authentication (optional):** Tools reach Blockscout data through the Blockscout PRO API. A hosted server is normally already configured with its own key, but when you connect to an HTTP MCP server you may supply your own PRO API key in the `Blockscout-MCP-Pro-Api-Key` request header. A client-supplied key takes precedence over the server's own key for that request. The REST API interface ignores this header and always authenticates with the server's configured key. + **Key Tools:** `get_address_info`, `get_transactions_by_address`, `get_tokens_by_address` ### REST API Interface diff --git a/blockscout_mcp_server/pro_api_key_context.py b/blockscout_mcp_server/pro_api_key_context.py new file mode 100644 index 00000000..6897b60e --- /dev/null +++ b/blockscout_mcp_server/pro_api_key_context.py @@ -0,0 +1,266 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +"""Request-scoped PRO API key resolution for MCP tool calls. + +This module owns everything about a client-supplied PRO API key: +- An immutable state representation (absent / valid / malformed). +- A module-level ContextVar holding that state. +- A normalization/validation helper. +- An extractor that reads the key from an MCP context. +- A resolver that applies the client-key → server-key precedence rule. +- A ``require_pro_api_key()`` helper that wraps the resolver with the standard + not-configured error so every PRO API entry point raises the same message. +- A @pro_api_key_scope decorator that populates the ContextVar per request. + +Kept intentionally separate from tools/decorators.py so authentication and +observability remain decoupled. + +Blanket decorator application +----------------------------- +``@pro_api_key_scope`` is applied to *every* MCP tool, including tools that +never call the PRO API (e.g. ``get_chains_list``, ``get_address_by_ens_name``, +``__unlock_blockchain_analysis__``). For those tools the recorded state is +never consulted — a malformed client header is effectively a no-op — but +applying the decorator uniformly means a future contributor cannot accidentally +add a PRO API call to a tool that lacks request-scoped key resolution. Do not +"optimize" by removing it from a tool that today doesn't need it. +""" + +from __future__ import annotations + +import functools +import inspect +import logging +from collections.abc import Awaitable, Callable +from contextvars import ContextVar +from dataclasses import dataclass +from typing import Any + +from blockscout_mcp_server.client_meta import get_header_case_insensitive +from blockscout_mcp_server.config import config + +logger = logging.getLogger(__name__) + +# --------------------------------------------------------------------------- +# Maximum length accepted for a client-supplied PRO API key value. +# Blockscout PRO API keys are 79 characters today; 256 leaves ~4x headroom for +# future format changes while still rejecting obvious abuse (multi-KB payloads +# that would only inflate the per-invocation ContextVar / log paths). +# --------------------------------------------------------------------------- +_MAX_KEY_LENGTH = 256 + + +# --------------------------------------------------------------------------- +# 1. Immutable state representation +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class _Absent: + """No client key was provided (feature disabled / not an MCP call / blank header).""" + + +@dataclass(frozen=True) +class _Valid: + """A well-formed client key string (local validation only).""" + + value: str + + +@dataclass(frozen=True) +class _Malformed: + """A key was supplied but failed local validation.""" + + +# Public type alias for the three states. +ClientKeyState = _Absent | _Valid | _Malformed + +_ABSENT: ClientKeyState = _Absent() +_MALFORMED: ClientKeyState = _Malformed() + + +# --------------------------------------------------------------------------- +# 2. Module-level ContextVar (not per-call — required for correct semantics) +# --------------------------------------------------------------------------- + +_client_key_state: ContextVar[ClientKeyState] = ContextVar("_client_key_state", default=_ABSENT) + + +# --------------------------------------------------------------------------- +# 3. Normalization / validation helper +# --------------------------------------------------------------------------- + + +def _normalize_key(raw: Any) -> ClientKeyState: + """Return one of the three states for *raw* header value. + + - Non-string → absent (defensive against unexpected mapping shapes). + - Empty / blank after stripping → absent. + - Contains control characters or exceeds max length → malformed. + - Otherwise → valid with the stripped value. + """ + if not isinstance(raw, str): + return _ABSENT + + stripped = raw.strip() + if not stripped: + return _ABSENT + + if len(stripped) > _MAX_KEY_LENGTH: + return _MALFORMED + + if any(ord(c) < 32 or ord(c) == 127 for c in stripped): + return _MALFORMED + + return _Valid(value=stripped) + + +# --------------------------------------------------------------------------- +# 4. Extractor — reads the header from an MCP context +# --------------------------------------------------------------------------- + + +def extract_client_pro_api_key_from_ctx(ctx: Any) -> ClientKeyState: + """Extract the client PRO API key state from an MCP context. + + Returns *absent* when: + - The feature is disabled (``config.pro_api_key_header`` is empty). + - The call comes from the REST layer (``ctx.call_source == "rest"``). + - There is no HTTP request context (e.g. stdio transport). + - Any unexpected context shape is encountered. + + Never raises. + """ + try: + if not config.pro_api_key_header: + return _ABSENT + + if getattr(ctx, "call_source", None) == "rest": + return _ABSENT + + request_context = getattr(ctx, "request_context", None) + if request_context is None: + return _ABSENT + + request = getattr(request_context, "request", None) + if request is None: + return _ABSENT + + headers = getattr(request, "headers", None) + if headers is None: + return _ABSENT + + raw = get_header_case_insensitive(headers, config.pro_api_key_header, "") + return _normalize_key(raw) + + except Exception: + # Defensive: an unexpected ctx shape (e.g. after an MCP transport + # upgrade) must never break the auth path. Log at DEBUG so the bug is + # discoverable without breaking the request. + logger.debug("Unexpected error extracting client PRO API key from ctx", exc_info=True) + return _ABSENT + + +# --------------------------------------------------------------------------- +# 5. Resolver — applies client-key → server-key precedence +# --------------------------------------------------------------------------- + + +def resolve_pro_api_key() -> str: + """Return the effective PRO API key for the current request. + + Precedence: + 1. Client-supplied key (valid) → use it. + 2. Client-supplied key (malformed) → raise ``ValueError`` immediately. + No fallback to the server key for a malformed submission. + 3. No client key (absent) → return ``config.pro_api_key`` (may be ``""``). + + Callers' existing emptiness guards handle the ``""`` case. + """ + state = _client_key_state.get() + + if isinstance(state, _Valid): + return state.value + + if isinstance(state, _Malformed): + raise ValueError( + "The supplied PRO API key header value is malformed: it contains control characters " + "or exceeds the maximum allowed length. Please provide a valid key." + ) + + # _Absent — fall back to the configured server key. + return config.pro_api_key + + +# --------------------------------------------------------------------------- +# 6. require_pro_api_key — single chokepoint for the "not configured" error +# --------------------------------------------------------------------------- + + +def require_pro_api_key(disabled_feature: str) -> str: + """Return the effective PRO API key or raise the standard not-configured error. + + Propagates ``ValueError`` from :func:`resolve_pro_api_key` for a malformed + client key. When both the client key and the server key are absent, raises + a ``ValueError`` whose message names ``BLOCKSCOUT_PRO_API_KEY`` and — when + client-supplied keys are enabled — the configured request header. Callers + pass a short ``disabled_feature`` label ("data access", "address metadata", + "contract reads via the PRO API gateway") so the caller's context survives + without each call site duplicating the full sentence. + """ + key = resolve_pro_api_key() + if not key: + hint = "set BLOCKSCOUT_PRO_API_KEY" + if config.pro_api_key_header: + hint = f"set BLOCKSCOUT_PRO_API_KEY on the server, or send the {config.pro_api_key_header} request header" + raise ValueError(f"Blockscout PRO API key is not configured ({hint}); {disabled_feature} is disabled.") + return key + + +# --------------------------------------------------------------------------- +# 7. Decorator — populates the ContextVar for the duration of a tool call +# --------------------------------------------------------------------------- + + +def pro_api_key_scope(func: Callable[..., Awaitable[Any]]) -> Callable[..., Awaitable[Any]]: + """Decorator that records the per-request client PRO API key state. + + Wraps an async tool function. Extracts ``ctx`` from the call arguments + (positional or keyword), resolves the client key state, sets the ContextVar, + and resets it in ``finally`` regardless of outcome. + + The decorator **never raises** for a malformed key — it only records state. + The raise happens later in ``resolve_pro_api_key()`` at the PRO API chokepoint. + + Uses ``functools.wraps`` to preserve the wrapped function's signature so + FastMCP schema generation and REST parameter binding continue to work. + + Stacking order + -------------- + Apply this decorator *inside* (closer to the function than) + ``@log_tool_invocation`` — that is:: + + @log_tool_invocation + @pro_api_key_scope + async def my_tool(...): ... + + Consequence: ``log_tool_invocation`` (and the analytics call it makes) runs + *outside* this scope and therefore cannot read the ContextVar. Analytics + must continue to derive any client-supplied-key signal from ``ctx`` headers + directly, never from ``_client_key_state``. + """ + sig = inspect.signature(func) + + @functools.wraps(func) + async def wrapper(*args: Any, **kwargs: Any) -> Any: + bound = sig.bind_partial(*args, **kwargs) + bound.apply_defaults() + ctx = dict(bound.arguments).get("ctx", None) + + state = extract_client_pro_api_key_from_ctx(ctx) + token = _client_key_state.set(state) + try: + return await func(*args, **kwargs) + finally: + _client_key_state.reset(token) + + return wrapper diff --git a/blockscout_mcp_server/tools/address/get_address_info.py b/blockscout_mcp_server/tools/address/get_address_info.py index 8f718a8d..fbaa0004 100644 --- a/blockscout_mcp_server/tools/address/get_address_info.py +++ b/blockscout_mcp_server/tools/address/get_address_info.py @@ -13,6 +13,7 @@ FirstTransactionDetails, ToolResponse, ) +from blockscout_mcp_server.pro_api_key_context import pro_api_key_scope from blockscout_mcp_server.tools.common import ( _recursively_truncate_and_flag_long_strings, build_tool_response, @@ -56,6 +57,7 @@ def _process_metadata_tags(metadata_data: dict | None) -> tuple[dict | None, boo @log_tool_invocation +@pro_api_key_scope async def get_address_info( chain_id: Annotated[str, Field(description="The ID of the blockchain")], address: Annotated[str, Field(description="Address to get information about")], diff --git a/blockscout_mcp_server/tools/address/get_tokens_by_address.py b/blockscout_mcp_server/tools/address/get_tokens_by_address.py index 656a446b..0dd749cc 100644 --- a/blockscout_mcp_server/tools/address/get_tokens_by_address.py +++ b/blockscout_mcp_server/tools/address/get_tokens_by_address.py @@ -10,6 +10,7 @@ TokenHoldingData, ToolResponse, ) +from blockscout_mcp_server.pro_api_key_context import pro_api_key_scope from blockscout_mcp_server.tools.common import ( apply_cursor_to_params, build_tool_response, @@ -21,6 +22,7 @@ @log_tool_invocation +@pro_api_key_scope async def get_tokens_by_address( chain_id: Annotated[str, Field(description="The ID of the blockchain")], address: Annotated[str, Field(description="Wallet address")], diff --git a/blockscout_mcp_server/tools/address/nft_tokens_by_address.py b/blockscout_mcp_server/tools/address/nft_tokens_by_address.py index 17e1655d..d9d674f0 100644 --- a/blockscout_mcp_server/tools/address/nft_tokens_by_address.py +++ b/blockscout_mcp_server/tools/address/nft_tokens_by_address.py @@ -11,6 +11,7 @@ NftTokenInstance, ToolResponse, ) +from blockscout_mcp_server.pro_api_key_context import pro_api_key_scope from blockscout_mcp_server.tools.common import ( apply_cursor_to_params, build_tool_response, @@ -37,6 +38,7 @@ def extract_nft_cursor_params(item: dict) -> dict: @log_tool_invocation +@pro_api_key_scope async def nft_tokens_by_address( chain_id: Annotated[str, Field(description="The ID of the blockchain")], address: Annotated[str, Field(description="NFT owner address")], diff --git a/blockscout_mcp_server/tools/block/get_block_info.py b/blockscout_mcp_server/tools/block/get_block_info.py index c78fe9a9..43933bbf 100644 --- a/blockscout_mcp_server/tools/block/get_block_info.py +++ b/blockscout_mcp_server/tools/block/get_block_info.py @@ -7,6 +7,7 @@ from blockscout_mcp_server.config import config from blockscout_mcp_server.models import BlockInfoData, ToolResponse +from blockscout_mcp_server.pro_api_key_context import pro_api_key_scope from blockscout_mcp_server.tools.common import ( build_tool_response, make_blockscout_request, @@ -16,6 +17,7 @@ @log_tool_invocation +@pro_api_key_scope async def get_block_info( chain_id: Annotated[str, Field(description="The ID of the blockchain")], number_or_hash: Annotated[str, Field(description="Block number or hash")], diff --git a/blockscout_mcp_server/tools/block/get_block_number.py b/blockscout_mcp_server/tools/block/get_block_number.py index f3efd9c4..9976fc16 100644 --- a/blockscout_mcp_server/tools/block/get_block_number.py +++ b/blockscout_mcp_server/tools/block/get_block_number.py @@ -7,6 +7,7 @@ from blockscout_mcp_server.config import config from blockscout_mcp_server.models import BlockNumberData, ToolResponse +from blockscout_mcp_server.pro_api_key_context import pro_api_key_scope from blockscout_mcp_server.tools.common import ( build_tool_response, make_blockscout_request, @@ -29,6 +30,7 @@ def _parse_datetime_to_timestamp(value: str) -> int: @log_tool_invocation +@pro_api_key_scope async def get_block_number( chain_id: Annotated[str, Field(description="The ID of the blockchain")], ctx: Context, diff --git a/blockscout_mcp_server/tools/chains/get_chains_list.py b/blockscout_mcp_server/tools/chains/get_chains_list.py index 4d1356ec..791d7329 100644 --- a/blockscout_mcp_server/tools/chains/get_chains_list.py +++ b/blockscout_mcp_server/tools/chains/get_chains_list.py @@ -5,6 +5,7 @@ from pydantic import Field from blockscout_mcp_server.models import ChainInfo, ToolResponse +from blockscout_mcp_server.pro_api_key_context import pro_api_key_scope from blockscout_mcp_server.tools.common import ( build_tool_response, chains_list_cache, @@ -16,6 +17,7 @@ @log_tool_invocation +@pro_api_key_scope async def get_chains_list( ctx: Context, query: Annotated[ diff --git a/blockscout_mcp_server/tools/common.py b/blockscout_mcp_server/tools/common.py index b5c7230d..769f08e2 100644 --- a/blockscout_mcp_server/tools/common.py +++ b/blockscout_mcp_server/tools/common.py @@ -18,6 +18,7 @@ SERVER_VERSION, ) from blockscout_mcp_server.models import NextCallInfo, PaginationInfo, ToolResponse +from blockscout_mcp_server.pro_api_key_context import require_pro_api_key, resolve_pro_api_key logger = logging.getLogger(__name__) @@ -202,10 +203,7 @@ async def make_blockscout_request( network conditions. Centralizing minimal retries here improves robustness for all tools and REST endpoints without masking persistent API errors. """ - if not config.pro_api_key: - raise ValueError( - "Blockscout PRO API key is not configured (set BLOCKSCOUT_PRO_API_KEY); data access is disabled." - ) + require_pro_api_key("data access") # Validate per request: cheap on a warm cache; keeps this helper the one chokepoint no caller can bypass. await ensure_chain_supported(chain_id) base_url = f"{config.pro_api_base_url}/{chain_id}" @@ -246,10 +244,7 @@ async def make_blockscout_post_request( retries occur only for connection-establishment failures (ConnectError, ConnectTimeout), where the request body is known not to have reached upstream. """ - if not config.pro_api_key: - raise ValueError( - "Blockscout PRO API key is not configured (set BLOCKSCOUT_PRO_API_KEY); data access is disabled." - ) + require_pro_api_key("data access") # Validate per request: cheap on a warm cache; keeps this helper the one chokepoint no caller can bypass. await ensure_chain_supported(chain_id) base_url = f"{config.pro_api_base_url}/{chain_id}" @@ -319,18 +314,22 @@ def _pro_api_headers() -> dict[str, str]: """Return HTTP headers for Blockscout PRO API requests. Always includes ``User-Agent`` and ``Accept: application/json``. - When ``config.pro_api_key`` is non-empty, also includes + When ``resolve_pro_api_key()`` returns a non-empty key, also includes ``Authorization: Bearer ``. Callers are expected to skip the request entirely when no key is configured (see ``make_metadata_request``), so the keyless branch here is purely defensive: it omits the ``Authorization`` header rather than sending a bare ``Bearer`` token. + + ``resolve_pro_api_key()`` may raise ``ValueError`` for a malformed + client key — that propagation is intended and must not be caught here. """ headers: dict[str, str] = { "User-Agent": f"{config.mcp_user_agent}/{SERVER_VERSION}", "Accept": "application/json", } - if config.pro_api_key: - headers["Authorization"] = f"Bearer {config.pro_api_key}" + effective_key = resolve_pro_api_key() + if effective_key: + headers["Authorization"] = f"Bearer {effective_key}" return headers @@ -417,10 +416,7 @@ async def make_metadata_request(api_path: str, params: dict | None = None) -> di httpx.RequestError: For transport-level errors surfaced after the final retry """ - if not config.pro_api_key: - raise ValueError( - "Blockscout PRO API key is not configured (set BLOCKSCOUT_PRO_API_KEY); address metadata is disabled." - ) + require_pro_api_key("address metadata") return await _make_blockscout_http_request( method="GET", base_url=config.pro_api_base_url, diff --git a/blockscout_mcp_server/tools/contract/_shared.py b/blockscout_mcp_server/tools/contract/_shared.py index 4928637f..8903ece6 100644 --- a/blockscout_mcp_server/tools/contract/_shared.py +++ b/blockscout_mcp_server/tools/contract/_shared.py @@ -3,6 +3,7 @@ from blockscout_mcp_server.cache import CachedContract, contract_cache from blockscout_mcp_server.config import config +from blockscout_mcp_server.pro_api_key_context import require_pro_api_key from blockscout_mcp_server.tools.common import ( _truncate_constructor_args, make_blockscout_request, @@ -24,8 +25,18 @@ def _determine_file_path(raw_data: dict[str, Any]) -> str: async def _fetch_and_process_contract(chain_id: str, address: str) -> CachedContract: """Fetch contract data from cache or Blockscout API.""" + # Gate on the effective PRO API key before the cache lookup so that a + # malformed or absent key fails closed even when protected data is cached. + # require_pro_api_key() propagates ValueError for a malformed client key + # and raises the standard not-configured error when both keys are absent. + require_pro_api_key("data access") + normalized_address = address.lower() cache_key = f"{chain_id}:{normalized_address}" + # Serving a cached contract requires only that some effective key be present + # (checked above), not that a client-supplied key was validated upstream. + # This is deliberate: the key authorizes the server's upstream requests, and + # a cache hit makes none, so there is nothing to authorize here. if cached := await contract_cache.get(cache_key): return cached diff --git a/blockscout_mcp_server/tools/contract/get_contract_abi.py b/blockscout_mcp_server/tools/contract/get_contract_abi.py index a8b650d6..cb89478a 100644 --- a/blockscout_mcp_server/tools/contract/get_contract_abi.py +++ b/blockscout_mcp_server/tools/contract/get_contract_abi.py @@ -6,6 +6,7 @@ from blockscout_mcp_server.config import config from blockscout_mcp_server.models import ContractAbiData, ToolResponse +from blockscout_mcp_server.pro_api_key_context import pro_api_key_scope from blockscout_mcp_server.tools.common import ( build_tool_response, make_blockscout_request, @@ -15,6 +16,7 @@ @log_tool_invocation +@pro_api_key_scope async def get_contract_abi( chain_id: Annotated[str, Field(description="The ID of the blockchain")], address: Annotated[str, Field(description="Smart contract address")], diff --git a/blockscout_mcp_server/tools/contract/inspect_contract_code.py b/blockscout_mcp_server/tools/contract/inspect_contract_code.py index b8c4f6ba..67ddfb71 100644 --- a/blockscout_mcp_server/tools/contract/inspect_contract_code.py +++ b/blockscout_mcp_server/tools/contract/inspect_contract_code.py @@ -9,12 +9,14 @@ ContractSourceFile, ToolResponse, ) +from blockscout_mcp_server.pro_api_key_context import pro_api_key_scope from blockscout_mcp_server.tools.common import build_tool_response, report_and_log_progress from blockscout_mcp_server.tools.contract._shared import _fetch_and_process_contract from blockscout_mcp_server.tools.decorators import log_tool_invocation @log_tool_invocation +@pro_api_key_scope async def inspect_contract_code( chain_id: Annotated[str, Field(description="The ID of the blockchain.")], address: Annotated[str, Field(description="The address of the smart contract.")], diff --git a/blockscout_mcp_server/tools/contract/read_contract.py b/blockscout_mcp_server/tools/contract/read_contract.py index 33ccc718..d4e11e09 100644 --- a/blockscout_mcp_server/tools/contract/read_contract.py +++ b/blockscout_mcp_server/tools/contract/read_contract.py @@ -9,6 +9,7 @@ from web3.utils.abi import check_if_arguments_can_be_encoded from blockscout_mcp_server.models import ContractReadData, ToolResponse +from blockscout_mcp_server.pro_api_key_context import pro_api_key_scope from blockscout_mcp_server.tools.common import build_tool_response, report_and_log_progress from blockscout_mcp_server.tools.decorators import log_tool_invocation from blockscout_mcp_server.web3_pool import WEB3_POOL @@ -44,6 +45,7 @@ def _convert_json_args(obj: Any) -> Any: @log_tool_invocation +@pro_api_key_scope async def read_contract( chain_id: Annotated[str, Field(description="The ID of the blockchain")], address: Annotated[str, Field(description="Smart contract address")], diff --git a/blockscout_mcp_server/tools/direct_api/direct_api_call.py b/blockscout_mcp_server/tools/direct_api/direct_api_call.py index 69264764..8c42313c 100644 --- a/blockscout_mcp_server/tools/direct_api/direct_api_call.py +++ b/blockscout_mcp_server/tools/direct_api/direct_api_call.py @@ -8,6 +8,7 @@ from blockscout_mcp_server.config import config from blockscout_mcp_server.constants import ALLOW_LARGE_RESPONSE_HEADER from blockscout_mcp_server.models import DirectApiData, NextCallInfo, PaginationInfo, ToolResponse +from blockscout_mcp_server.pro_api_key_context import pro_api_key_scope from blockscout_mcp_server.tools.common import ( ResponseTooLargeError, apply_cursor_to_params, @@ -23,6 +24,7 @@ @log_tool_invocation +@pro_api_key_scope async def direct_api_call( chain_id: Annotated[str, Field(description="The ID of the blockchain")], endpoint_path: Annotated[ diff --git a/blockscout_mcp_server/tools/ens/get_address_by_ens_name.py b/blockscout_mcp_server/tools/ens/get_address_by_ens_name.py index f0a51717..d902e849 100644 --- a/blockscout_mcp_server/tools/ens/get_address_by_ens_name.py +++ b/blockscout_mcp_server/tools/ens/get_address_by_ens_name.py @@ -5,6 +5,7 @@ from pydantic import Field from blockscout_mcp_server.models import EnsAddressData, ToolResponse +from blockscout_mcp_server.pro_api_key_context import pro_api_key_scope from blockscout_mcp_server.tools.common import ( build_tool_response, make_bens_request, @@ -14,6 +15,7 @@ @log_tool_invocation +@pro_api_key_scope async def get_address_by_ens_name( name: Annotated[str, Field(description="ENS domain name to resolve")], ctx: Context ) -> ToolResponse[EnsAddressData]: diff --git a/blockscout_mcp_server/tools/initialization/unlock_blockchain_analysis.py b/blockscout_mcp_server/tools/initialization/unlock_blockchain_analysis.py index e80ab128..bce3fc47 100644 --- a/blockscout_mcp_server/tools/initialization/unlock_blockchain_analysis.py +++ b/blockscout_mcp_server/tools/initialization/unlock_blockchain_analysis.py @@ -10,6 +10,7 @@ InstructionsData, ToolResponse, ) +from blockscout_mcp_server.pro_api_key_context import pro_api_key_scope from blockscout_mcp_server.tools.common import ( build_tool_response, report_and_log_progress, @@ -20,6 +21,7 @@ # It is very important to keep the tool description in such form to force the LLM to call this tool first # before calling any other tool. Altering of the description could provide opportunity to LLM to skip this tool. @log_tool_invocation +@pro_api_key_scope async def __unlock_blockchain_analysis__(ctx: Context) -> ToolResponse[InstructionsData]: """Mandatory initialization step for any session against the Blockscout MCP server. diff --git a/blockscout_mcp_server/tools/search/lookup_token_by_symbol.py b/blockscout_mcp_server/tools/search/lookup_token_by_symbol.py index 6fa60845..49322353 100644 --- a/blockscout_mcp_server/tools/search/lookup_token_by_symbol.py +++ b/blockscout_mcp_server/tools/search/lookup_token_by_symbol.py @@ -6,6 +6,7 @@ from blockscout_mcp_server.config import config from blockscout_mcp_server.models import TokenSearchResult, ToolResponse +from blockscout_mcp_server.pro_api_key_context import pro_api_key_scope from blockscout_mcp_server.tools.common import ( build_tool_response, make_blockscout_request, @@ -18,6 +19,7 @@ @log_tool_invocation +@pro_api_key_scope async def lookup_token_by_symbol( chain_id: Annotated[str, Field(description="The ID of the blockchain")], symbol: Annotated[str, Field(description="Token symbol or name to search for")], diff --git a/blockscout_mcp_server/tools/transaction/get_token_transfers_by_address.py b/blockscout_mcp_server/tools/transaction/get_token_transfers_by_address.py index a220623f..0240f8a7 100644 --- a/blockscout_mcp_server/tools/transaction/get_token_transfers_by_address.py +++ b/blockscout_mcp_server/tools/transaction/get_token_transfers_by_address.py @@ -6,6 +6,7 @@ from blockscout_mcp_server.config import config from blockscout_mcp_server.models import AdvancedFilterItem, ToolResponse +from blockscout_mcp_server.pro_api_key_context import pro_api_key_scope from blockscout_mcp_server.tools.common import ( apply_cursor_to_params, build_tool_response, @@ -20,6 +21,7 @@ @log_tool_invocation +@pro_api_key_scope async def get_token_transfers_by_address( chain_id: Annotated[str, Field(description="The ID of the blockchain")], address: Annotated[str, Field(description="Address which either transfer initiator or transfer receiver")], diff --git a/blockscout_mcp_server/tools/transaction/get_transaction_info.py b/blockscout_mcp_server/tools/transaction/get_transaction_info.py index 75963909..a01860a8 100644 --- a/blockscout_mcp_server/tools/transaction/get_transaction_info.py +++ b/blockscout_mcp_server/tools/transaction/get_transaction_info.py @@ -7,6 +7,7 @@ from blockscout_mcp_server.config import config from blockscout_mcp_server.models import ToolResponse, TransactionInfoData +from blockscout_mcp_server.pro_api_key_context import pro_api_key_scope from blockscout_mcp_server.tools.common import ( build_tool_response, make_blockscout_request, @@ -21,6 +22,7 @@ @log_tool_invocation +@pro_api_key_scope async def get_transaction_info( chain_id: Annotated[str, Field(description="The ID of the blockchain")], transaction_hash: Annotated[str, Field(description="Transaction hash")], diff --git a/blockscout_mcp_server/tools/transaction/get_transactions_by_address.py b/blockscout_mcp_server/tools/transaction/get_transactions_by_address.py index 480a6f44..4e77f086 100644 --- a/blockscout_mcp_server/tools/transaction/get_transactions_by_address.py +++ b/blockscout_mcp_server/tools/transaction/get_transactions_by_address.py @@ -6,6 +6,7 @@ from blockscout_mcp_server.config import config from blockscout_mcp_server.models import AdvancedFilterItem, ToolResponse +from blockscout_mcp_server.pro_api_key_context import pro_api_key_scope from blockscout_mcp_server.tools.common import ( apply_cursor_to_params, build_tool_response, @@ -21,6 +22,7 @@ @log_tool_invocation +@pro_api_key_scope async def get_transactions_by_address( chain_id: Annotated[str, Field(description="The ID of the blockchain")], address: Annotated[str, Field(description="Address which either sender or receiver of the transaction")], diff --git a/blockscout_mcp_server/web3_pool.py b/blockscout_mcp_server/web3_pool.py index 76b293a0..b9ce8998 100644 --- a/blockscout_mcp_server/web3_pool.py +++ b/blockscout_mcp_server/web3_pool.py @@ -15,6 +15,14 @@ instance is slow or under heavy load. The ``BLOCKSCOUT_MCP_USER_AGENT`` variable customizes the leading part of the ``User-Agent`` header; the server version is appended automatically. + +Authorization is injected at request time inside +:meth:`AsyncHTTPProviderBlockscout._make_http_request` by calling +:func:`~blockscout_mcp_server.pro_api_key_context.resolve_pro_api_key`. This +means each in-flight request resolves the effective key from the request-scoped +``ContextVar`` rather than reading it from the shared provider instance, so two +concurrent requests with different client keys cannot cross-contaminate each +other's headers. """ from __future__ import annotations @@ -29,6 +37,7 @@ from blockscout_mcp_server.config import config from blockscout_mcp_server.constants import SERVER_VERSION +from blockscout_mcp_server.pro_api_key_context import require_pro_api_key, resolve_pro_api_key from blockscout_mcp_server.tools.common import ensure_chain_supported @@ -39,20 +48,6 @@ def _default_headers() -> dict[str, str]: } -def _request_headers(hdr_items: tuple[tuple[str, str], ...]) -> dict[str, str]: - """Build full request headers from non-secret cache-key items. - - Copies the non-secret header items and appends ``Authorization: Bearer - `` only when ``config.pro_api_key`` is non-empty (never send a bare - ``Bearer``). The ``Authorization`` header is therefore never stored in - cache keys; it is resolved at request time on every call. - """ - headers = dict(hdr_items) - if config.pro_api_key: - headers["Authorization"] = f"Bearer {config.pro_api_key}" - return headers - - class AsyncHTTPProviderBlockscout(AsyncHTTPProvider): """Custom provider with Blockscout-specific adaptations. @@ -67,9 +62,13 @@ class AsyncHTTPProviderBlockscout(AsyncHTTPProvider): managed ``aiohttp.ClientSession`` to be reused for all requests, enabling connection pooling and fine-grained timeout control. - The :meth:`set_request_headers` method replaces the provider's request - headers, letting the pool (re)apply the configured ``Authorization`` to a - reused provider without creating a new instance. + The provider's stored ``_request_kwargs["headers"]`` contain only + non-secret headers (``User-Agent`` and any caller-supplied non-auth + headers). ``Authorization`` is **never** stored on the provider; it is + resolved at request time inside :meth:`_make_http_request` via + :func:`~blockscout_mcp_server.pro_api_key_context.resolve_pro_api_key`, + so concurrent requests each carry their own key without mutating shared + state. """ def __init__(self, *args, **kwargs) -> None: @@ -82,24 +81,26 @@ def __init__(self, *args, **kwargs) -> None: def set_pooled_session(self, session: aiohttp.ClientSession) -> None: self.pooled_session = session - def set_request_headers(self, headers: dict[str, str]) -> None: - """Replace the provider's request headers. - - Mirrors :meth:`set_pooled_session` as a hook for the pool to (re)apply - the configured ``Authorization`` header on every fetch (including cache - hits), since auth is not part of the provider's cache key. - """ - self._request_kwargs["headers"] = headers - async def _make_http_request(self, session: aiohttp.ClientSession, rpc_dict: dict[str, Any]) -> dict[str, Any]: """Perform the HTTP request using the given session. A dedicated helper lets us share the implementation between pooled and fallback sessions while keeping tight control over timeouts. + + ``Authorization`` is injected here at request time by calling + :func:`~blockscout_mcp_server.pro_api_key_context.resolve_pro_api_key`. + A fresh header dict is built on every call so the shared provider + instance is never mutated and concurrent requests remain isolated. """ headers = dict(self._request_kwargs.get("headers", {})) headers.setdefault("Content-Type", "application/json") headers.setdefault("Accept", "application/json") + # Resolve the effective PRO API key for this specific request. + # resolve_pro_api_key() raises ValueError for a malformed client key, + # which propagates out of the JSON-RPC call as the tool error. + effective_key = resolve_pro_api_key() + if effective_key: + headers["Authorization"] = f"Bearer {effective_key}" timeout = aiohttp.ClientTimeout(total=self._request_kwargs.get("timeout", config.rpc_request_timeout)) async with session.post( self.endpoint_uri, @@ -146,9 +147,11 @@ class Web3Pool: chain's JSON-RPC traffic targets the same host (``api.blockscout.com``). Auth headers (``Authorization``) are intentionally excluded from cache - keys so the secret never enters internal dictionaries; the configured key - is (re)applied on every fetch (including cache hits), so a pooled provider - always carries the currently configured key. + keys and from the provider's stored headers. The effective key is resolved + per request inside :meth:`AsyncHTTPProviderBlockscout._make_http_request` + via :func:`~blockscout_mcp_server.pro_api_key_context.resolve_pro_api_key`, + so two concurrent requests against the same pooled provider each carry + their own key without any shared-state mutation. """ def __init__(self) -> None: @@ -179,13 +182,12 @@ async def _get_session(self) -> aiohttp.ClientSession: return self._session async def get(self, chain_id: str, headers: dict[str, str] | None = None) -> AsyncWeb3: - # Fail fast when no PRO API key is configured — no network call should be - # made when the gateway is guaranteed to reject the request. - if not config.pro_api_key: - raise ValueError( - "Blockscout PRO API key is not configured (set BLOCKSCOUT_PRO_API_KEY); " - "contract reads via the PRO API gateway are disabled." - ) + # Fail fast when no effective PRO API key is available — no network call + # should be made when the gateway is guaranteed to reject the request. + # require_pro_api_key() propagates ValueError for a malformed client + # key and raises the standard not-configured error when both keys are + # absent. + require_pro_api_key("contract reads via the PRO API gateway") # Validate the chain before constructing anything. await ensure_chain_supported(chain_id) @@ -204,10 +206,6 @@ async def get(self, chain_id: str, headers: dict[str, str] | None = None) -> Asy if key in self._pool: w3 = self._pool[key] - # Re-apply auth headers on every fetch — including cache hits — since auth - # is excluded from the cache key, so the provider always carries the - # currently configured key. - w3.provider.set_request_headers(_request_headers(hdr_items)) w3.provider.set_pooled_session(session) return w3 @@ -216,7 +214,7 @@ async def get(self, chain_id: str, headers: dict[str, str] | None = None) -> Asy provider = AsyncHTTPProviderBlockscout( endpoint_uri=endpoint, request_kwargs={ - "headers": _request_headers(hdr_items), + "headers": dict(hdr_items), "timeout": config.rpc_request_timeout, }, ) diff --git a/pyproject.toml b/pyproject.toml index 2f4e6dcb..7c385163 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "blockscout-mcp-server" -version = "0.16.0.dev15" +version = "0.16.0.dev16" description = "MCP server for Blockscout" requires-python = ">=3.11" dependencies = [ diff --git a/server.json b/server.json index fddaf76d..c7c62a66 100644 --- a/server.json +++ b/server.json @@ -2,7 +2,7 @@ "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json", "name": "com.blockscout/mcp-server", "description": "MCP server for Blockscout", - "version": "0.16.0.dev15", + "version": "0.16.0.dev16", "websiteUrl": "https://blockscout.com", "repository": { "url": "https://github.com/blockscout/mcp-server", diff --git a/tests/integration/test_common_helpers.py b/tests/integration/test_common_helpers.py index 57f58358..cf83b49d 100644 --- a/tests/integration/test_common_helpers.py +++ b/tests/integration/test_common_helpers.py @@ -3,6 +3,7 @@ import pytest from blockscout_mcp_server.config import config +from blockscout_mcp_server.pro_api_key_context import _client_key_state, _Valid from blockscout_mcp_server.tools.common import ( ChainNotFoundError, ensure_chain_supported, @@ -161,3 +162,47 @@ async def test_ensure_chain_supported_for_bogus_chain(): """ with pytest.raises(ChainNotFoundError): await ensure_chain_supported("99999999") + + +@pytest.mark.integration +@pytest.mark.asyncio +@pytest.mark.skipif(not config.pro_api_key, reason="BLOCKSCOUT_PRO_API_KEY not configured") +async def test_make_blockscout_request_client_key_via_context_var(monkeypatch): + """ + Validates that a well-formed key placed in the request-scoped ContextVar is what + actually authenticates a live PRO API request when config.pro_api_key is blank. + + This closes the gap that mocked unit tests leave: it proves that a resolved client + key carries a real request against the live gateway (not just under mocks). Header + extraction and decorator scoping are proven separately in Phases 2–3; this test does + not exercise them. + """ + # ARRANGE + # Capture the configured key as the stand-in client key, then blank the server key so + # any accidental server-key fallback would fail the request rather than mask the bug. + client_key = config.pro_api_key + monkeypatch.setattr(config, "pro_api_key", "") + + # Set the ContextVar to the valid state holding the client key; reset it in finally + # so the state never leaks into other tests. + token = _client_key_state.set(_Valid(value=client_key)) + try: + chain_id = "100" # Gnosis Chain + block_number = "46282564" + api_path = f"/api/v2/blocks/{block_number}" + + # ACT — same chain/path as test_make_blockscout_request_for_block_info, wrapped + # in retry_on_network_error for transient-failure resilience. + response_data = await retry_on_network_error( + lambda: make_blockscout_request(chain_id=chain_id, api_path=api_path), + action_description="PRO API block request with client key via ContextVar", + ) + finally: + _client_key_state.reset(token) + + # ASSERT — a successful response proves the client key alone carried the request. + assert isinstance(response_data, dict) + assert response_data["height"] == 46282564 + assert "timestamp" in response_data + assert isinstance(response_data["gas_used"], str) # Blockscout API returns this as a string + assert "parent_hash" in response_data diff --git a/tests/test_analytics.py b/tests/test_analytics.py index f96fa8ef..cb7df867 100644 --- a/tests/test_analytics.py +++ b/tests/test_analytics.py @@ -170,6 +170,46 @@ def test_track_event_noop_when_disabled(monkeypatch): mp_cls.assert_not_called() +def test_pro_api_key_not_in_analytics_payload(monkeypatch): + """The client-supplied PRO API key must not appear in the Mixpanel payload or kwargs. + + analytics.track_tool_invocation runs inside log_tool_invocation, which executes + *outside* the pro_api_key_scope. Even so, this test exercises the case where the + context object carries the configured header to ensure no code path leaks it. + """ + monkeypatch.setattr(server_config, "mixpanel_token", "test-token", raising=False) + + client_key = "client-secret" + headers = { + "x-forwarded-for": "203.0.113.5", + "user-agent": "pytest-UA", + "Blockscout-MCP-Pro-Api-Key": client_key, + } + req = DummyRequest(headers=headers) + ctx = DummyCtx(request=req, client_name="test-client", client_version="1.0.0") + + with patch("blockscout_mcp_server.analytics.Mixpanel") as mp_cls: + mp_instance = MagicMock() + mp_cls.return_value = mp_instance + analytics.set_http_mode(True) + analytics.track_tool_invocation(ctx, "some_tool", {"x": 1}) + + assert mp_instance.track.called + call_args = mp_instance.track.call_args + + # Inspect every string in the call for the key value and header name + all_text = str(call_args) + assert client_key not in all_text + # Case-insensitive: catch a leaked header name regardless of casing. + assert "blockscout-mcp-pro-api-key" not in all_text.lower() + + # Also explicitly check that the properties dict doesn't contain the key + args, kwargs = call_args + properties = args[2] if len(args) > 2 else {} + assert client_key not in str(properties) + assert client_key not in str(kwargs) + + def test_track_community_usage(monkeypatch): monkeypatch.setattr(server_config, "mixpanel_token", "test-token", raising=False) with patch("blockscout_mcp_server.analytics.Mixpanel") as mp_cls: diff --git a/tests/test_config.py b/tests/test_config.py index 53f5234d..a461565b 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -100,3 +100,27 @@ def test_pro_api_key_whitespace_only_becomes_empty(monkeypatch): monkeypatch.setenv("BLOCKSCOUT_PRO_API_KEY", " ") cfg = ServerConfig(_env_file=None) assert cfg.pro_api_key == "" + + +def test_pro_api_key_header_default(monkeypatch): + monkeypatch.delenv("BLOCKSCOUT_PRO_API_KEY_HEADER", raising=False) + cfg = ServerConfig(_env_file=None) + assert cfg.pro_api_key_header == "Blockscout-MCP-Pro-Api-Key" + + +def test_pro_api_key_header_env_override(monkeypatch): + monkeypatch.setenv("BLOCKSCOUT_PRO_API_KEY_HEADER", "X-Custom-Pro-Key") + cfg = ServerConfig(_env_file=None) + assert cfg.pro_api_key_header == "X-Custom-Pro-Key" + + +def test_pro_api_key_header_strips_surrounding_whitespace(monkeypatch): + monkeypatch.setenv("BLOCKSCOUT_PRO_API_KEY_HEADER", " Blockscout-MCP-Pro-Api-Key ") + cfg = ServerConfig(_env_file=None) + assert cfg.pro_api_key_header == "Blockscout-MCP-Pro-Api-Key" + + +def test_pro_api_key_header_empty_string_preserved(monkeypatch): + monkeypatch.setenv("BLOCKSCOUT_PRO_API_KEY_HEADER", "") + cfg = ServerConfig(_env_file=None) + assert cfg.pro_api_key_header == "" diff --git a/tests/test_pro_api_key_context.py b/tests/test_pro_api_key_context.py new file mode 100644 index 00000000..8bd5a923 --- /dev/null +++ b/tests/test_pro_api_key_context.py @@ -0,0 +1,424 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +"""Unit tests for blockscout_mcp_server.pro_api_key_context.""" + +from __future__ import annotations + +import asyncio +from contextlib import contextmanager +from types import SimpleNamespace +from unittest.mock import MagicMock + +import pytest +from starlette.datastructures import Headers + +from blockscout_mcp_server.config import config +from blockscout_mcp_server.pro_api_key_context import ( + _Absent, + _client_key_state, + _Malformed, + _normalize_key, + _Valid, + extract_client_pro_api_key_from_ctx, + pro_api_key_scope, + resolve_pro_api_key, +) + +# --------------------------------------------------------------------------- +# Test-isolation helper: set the ContextVar directly and always reset it. +# Exercising state through @pro_api_key_scope is inherently safe (the decorator +# already resets its own token). Only direct-set tests need this helper. +# --------------------------------------------------------------------------- + + +@contextmanager +def _set_key_state(state): + """Context manager that sets _client_key_state and resets it in finally.""" + token = _client_key_state.set(state) + try: + yield + finally: + _client_key_state.reset(token) + + +# =========================================================================== +# Normalization / validation +# =========================================================================== + + +def test_normalize_plain_value_is_valid(): + state = _normalize_key("abc-123") + assert isinstance(state, _Valid) + assert state.value == "abc-123" + + +def test_normalize_strips_whitespace(): + state = _normalize_key(" my-key ") + assert isinstance(state, _Valid) + assert state.value == "my-key" + + +def test_normalize_empty_string_is_absent(): + assert isinstance(_normalize_key(""), _Absent) + + +def test_normalize_blank_string_is_absent(): + assert isinstance(_normalize_key(" "), _Absent) + + +def test_normalize_non_string_mock_is_absent(): + assert isinstance(_normalize_key(MagicMock()), _Absent) + + +def test_normalize_non_string_int_is_absent(): + assert isinstance(_normalize_key(42), _Absent) + + +def test_normalize_control_char_newline_is_malformed(): + state = _normalize_key("valid-prefix\ninjected") + assert isinstance(state, _Malformed) + + +def test_normalize_control_char_carriage_return_is_malformed(): + state = _normalize_key("key\r\ninjection") + assert isinstance(state, _Malformed) + + +def test_normalize_control_char_tab_is_malformed(): + state = _normalize_key("key\twith-tab") + assert isinstance(state, _Malformed) + + +def test_normalize_control_char_del_is_malformed(): + state = _normalize_key("key\x7f") + assert isinstance(state, _Malformed) + + +def test_normalize_over_length_is_malformed(): + state = _normalize_key("a" * 257) + assert isinstance(state, _Malformed) + + +def test_normalize_exactly_max_length_is_valid(): + state = _normalize_key("a" * 256) + assert isinstance(state, _Valid) + + +# =========================================================================== +# Extraction scoping +# =========================================================================== + + +def _mcp_ctx_with_header(header_name: str, header_value: str) -> SimpleNamespace: + """Build a minimal MCP-like context carrying *header_value* under *header_name*. + + Uses real ``starlette.datastructures.Headers`` with non-canonical casing to + exercise case-insensitive lookup. + """ + # Use non-canonical casing to prove case-insensitive lookup works. + # starlette.datastructures.Headers accepts a Mapping (dict) for the headers param. + headers = Headers(headers={header_name.upper(): header_value}) + request = SimpleNamespace(headers=headers) + return SimpleNamespace(request_context=SimpleNamespace(request=request)) + + +def test_extraction_rest_call_source_is_absent(monkeypatch): + monkeypatch.setattr(config, "pro_api_key_header", "Blockscout-MCP-Pro-Api-Key", raising=False) + ctx = _mcp_ctx_with_header("Blockscout-MCP-Pro-Api-Key", "client-key-123") + ctx.call_source = "rest" # type: ignore[attr-defined] + assert isinstance(extract_client_pro_api_key_from_ctx(ctx), _Absent) + + +def test_extraction_empty_header_config_is_absent(monkeypatch): + monkeypatch.setattr(config, "pro_api_key_header", "", raising=False) + ctx = _mcp_ctx_with_header("Blockscout-MCP-Pro-Api-Key", "client-key-123") + assert isinstance(extract_client_pro_api_key_from_ctx(ctx), _Absent) + + +def test_extraction_no_request_context_is_absent(monkeypatch): + monkeypatch.setattr(config, "pro_api_key_header", "Blockscout-MCP-Pro-Api-Key", raising=False) + ctx = SimpleNamespace() # no request_context attribute + assert isinstance(extract_client_pro_api_key_from_ctx(ctx), _Absent) + + +def test_extraction_none_request_context_is_absent(monkeypatch): + monkeypatch.setattr(config, "pro_api_key_header", "Blockscout-MCP-Pro-Api-Key", raising=False) + ctx = SimpleNamespace(request_context=None) + assert isinstance(extract_client_pro_api_key_from_ctx(ctx), _Absent) + + +def test_extraction_stdio_like_no_request_is_absent(monkeypatch): + monkeypatch.setattr(config, "pro_api_key_header", "Blockscout-MCP-Pro-Api-Key", raising=False) + ctx = SimpleNamespace(request_context=SimpleNamespace(request=None)) + assert isinstance(extract_client_pro_api_key_from_ctx(ctx), _Absent) + + +def test_extraction_mcp_ctx_with_valid_header(monkeypatch): + """Real starlette Headers + non-canonical casing → valid state.""" + monkeypatch.setattr(config, "pro_api_key_header", "Blockscout-MCP-Pro-Api-Key", raising=False) + # Pass the header under ALL-CAPS to prove case-insensitive lookup. + # starlette.datastructures.Headers accepts a Mapping (dict) for the headers param. + headers = Headers(headers={"BLOCKSCOUT-MCP-PRO-API-KEY": "my-client-key"}) + request = SimpleNamespace(headers=headers) + ctx = SimpleNamespace(request_context=SimpleNamespace(request=request)) + + state = extract_client_pro_api_key_from_ctx(ctx) + assert isinstance(state, _Valid) + assert state.value == "my-client-key" + + +def test_extraction_defensive_on_unexpected_ctx(): + """An entirely unexpected context shape must return absent, not raise.""" + state = extract_client_pro_api_key_from_ctx(object()) + assert isinstance(state, _Absent) + + +# =========================================================================== +# Resolution precedence matrix +# =========================================================================== + + +def test_resolve_client_valid_returns_client_key(monkeypatch): + monkeypatch.setattr(config, "pro_api_key", "server-key", raising=False) + with _set_key_state(_Valid(value="client-key")): + assert resolve_pro_api_key() == "client-key" + + +def test_resolve_absent_with_server_key(monkeypatch): + monkeypatch.setattr(config, "pro_api_key", "server-key", raising=False) + with _set_key_state(_Absent()): + assert resolve_pro_api_key() == "server-key" + + +def test_resolve_absent_with_empty_server_key(monkeypatch): + monkeypatch.setattr(config, "pro_api_key", "", raising=False) + with _set_key_state(_Absent()): + assert resolve_pro_api_key() == "" + + +def test_resolve_malformed_raises_value_error(monkeypatch): + monkeypatch.setattr(config, "pro_api_key", "server-key", raising=False) + with _set_key_state(_Malformed()): + with pytest.raises(ValueError): + resolve_pro_api_key() + + +def test_resolve_malformed_does_not_fall_back_to_server_key(monkeypatch): + monkeypatch.setattr(config, "pro_api_key", "server-key", raising=False) + with _set_key_state(_Malformed()): + with pytest.raises(ValueError) as exc_info: + resolve_pro_api_key() + # Error message must mention client-supplied key being malformed + assert "malformed" in str(exc_info.value).lower() + + +# =========================================================================== +# Secret redaction in the malformed error +# =========================================================================== + + +@pytest.mark.asyncio +async def test_malformed_error_does_not_embed_control_char_value(monkeypatch): + """The malformed-key ValueError must not reproduce the raw submitted value. + + The value flows through the real extraction → scope → resolve path: the + decorator extracts the control-char header, records the ``_Malformed`` state, + and ``resolve_pro_api_key()`` raises inside the scope. + """ + monkeypatch.setattr(config, "pro_api_key_header", "Blockscout-MCP-Pro-Api-Key", raising=False) + monkeypatch.setattr(config, "pro_api_key", "server-key", raising=False) + raw_value = "key-with\ncontrol-char" + + @pro_api_key_scope + async def dummy(ctx) -> None: + resolve_pro_api_key() + + # Plain dict headers: real starlette Headers reject control-char values at + # construction time, while the extractor works with any Mapping. + request = SimpleNamespace(headers={"Blockscout-MCP-Pro-Api-Key": raw_value}) + ctx = SimpleNamespace(request_context=SimpleNamespace(request=request)) + + with pytest.raises(ValueError) as exc_info: + await dummy(ctx=ctx) + assert raw_value not in str(exc_info.value) + + +@pytest.mark.asyncio +async def test_malformed_error_does_not_embed_over_length_value(monkeypatch): + """The malformed-key ValueError must not reproduce an over-length submitted value. + + Same real extraction → scope → resolve path as the control-char case. + """ + monkeypatch.setattr(config, "pro_api_key_header", "Blockscout-MCP-Pro-Api-Key", raising=False) + monkeypatch.setattr(config, "pro_api_key", "server-key", raising=False) + raw_value = "a" * 257 + + @pro_api_key_scope + async def dummy(ctx) -> None: + resolve_pro_api_key() + + request = SimpleNamespace(headers={"Blockscout-MCP-Pro-Api-Key": raw_value}) + ctx = SimpleNamespace(request_context=SimpleNamespace(request=request)) + + with pytest.raises(ValueError) as exc_info: + await dummy(ctx=ctx) + assert raw_value not in str(exc_info.value) + + +# =========================================================================== +# Decorator behaviour +# =========================================================================== + + +def _ctx_with_starlette_headers(header_name: str, header_value: str) -> SimpleNamespace: + """Build an MCP-like context using real starlette.datastructures.Headers (dict form). + + The header name is passed in UPPER CASE so the case-insensitive lookup is exercised. + """ + headers = Headers(headers={header_name.upper(): header_value}) + request = SimpleNamespace(headers=headers) + return SimpleNamespace(request_context=SimpleNamespace(request=request)) + + +@pytest.mark.asyncio +async def test_decorator_sets_valid_state_during_call(monkeypatch): + monkeypatch.setattr(config, "pro_api_key_header", "Blockscout-MCP-Pro-Api-Key", raising=False) + + observed_state: list[object] = [] + + @pro_api_key_scope + async def dummy(ctx) -> None: + observed_state.append(_client_key_state.get()) + + ctx = _ctx_with_starlette_headers("Blockscout-MCP-Pro-Api-Key", "client-key-xyz") + + await dummy(ctx=ctx) + + assert len(observed_state) == 1 + assert isinstance(observed_state[0], _Valid) + assert observed_state[0].value == "client-key-xyz" # type: ignore[union-attr] + + +@pytest.mark.asyncio +async def test_decorator_resets_state_after_call(monkeypatch): + monkeypatch.setattr(config, "pro_api_key_header", "Blockscout-MCP-Pro-Api-Key", raising=False) + + @pro_api_key_scope + async def dummy(ctx) -> None: + pass + + ctx = _ctx_with_starlette_headers("Blockscout-MCP-Pro-Api-Key", "client-key-xyz") + + await dummy(ctx=ctx) + + # After the call, the state should be back to the default (_Absent) + assert isinstance(_client_key_state.get(), _Absent) + + +@pytest.mark.asyncio +async def test_decorator_resets_state_even_when_wrapped_function_raises(monkeypatch): + monkeypatch.setattr(config, "pro_api_key_header", "Blockscout-MCP-Pro-Api-Key", raising=False) + + @pro_api_key_scope + async def dummy(ctx) -> None: + raise RuntimeError("boom") + + ctx = _ctx_with_starlette_headers("Blockscout-MCP-Pro-Api-Key", "client-key-xyz") + + with pytest.raises(RuntimeError, match="boom"): + await dummy(ctx=ctx) + + # State must be reset even after an exception + assert isinstance(_client_key_state.get(), _Absent) + + +@pytest.mark.asyncio +async def test_decorator_rest_call_source_leaves_state_absent(monkeypatch): + monkeypatch.setattr(config, "pro_api_key_header", "Blockscout-MCP-Pro-Api-Key", raising=False) + + observed_state: list[object] = [] + + @pro_api_key_scope + async def dummy(ctx) -> None: + observed_state.append(_client_key_state.get()) + + ctx = _ctx_with_starlette_headers("Blockscout-MCP-Pro-Api-Key", "client-key-xyz") + ctx.call_source = "rest" # type: ignore[attr-defined] + + await dummy(ctx=ctx) + + assert isinstance(observed_state[0], _Absent) + + +@pytest.mark.asyncio +async def test_decorator_malformed_does_not_raise_from_decorator(monkeypatch): + """The decorator must never raise for a malformed key; the wrapped function still runs. + + Uses a plain-dict headers stub because real starlette Headers reject header values + that contain control characters (latin-1 encoding would raise). The normalization + helper is responsible for detecting them; we test that via a plain string. + """ + monkeypatch.setattr(config, "pro_api_key_header", "Blockscout-MCP-Pro-Api-Key", raising=False) + + ran: list[bool] = [] + + @pro_api_key_scope + async def dummy(ctx) -> None: + ran.append(True) + + # Use a plain dict so we can inject a control-character value that starlette + # would refuse to encode — the extractor just calls get_header_case_insensitive + # which works with any Mapping. + headers = {"Blockscout-MCP-Pro-Api-Key": "bad\x00key"} + request = SimpleNamespace(headers=headers) + ctx = SimpleNamespace(request_context=SimpleNamespace(request=request)) + + # Must not raise at decoration time or call time (malformed raise is in resolve_pro_api_key) + await dummy(ctx=ctx) + assert ran == [True] + + +@pytest.mark.asyncio +async def test_decorator_preserves_wrapped_function_name(): + @pro_api_key_scope + async def my_tool(ctx) -> None: + pass + + assert my_tool.__name__ == "my_tool" + + +# =========================================================================== +# Per-task isolation +# =========================================================================== + + +@pytest.mark.asyncio +async def test_per_task_isolation(monkeypatch): + """Two concurrent decorated coroutines, each with a different client key, + must each observe their own resolved key inside their body. + """ + monkeypatch.setattr(config, "pro_api_key_header", "Blockscout-MCP-Pro-Api-Key", raising=False) + monkeypatch.setattr(config, "pro_api_key", "server-key", raising=False) + + results: dict[str, str] = {} + + @pro_api_key_scope + async def dummy(ctx, task_name: str) -> None: + # Yield control so the other coroutine can start. + await asyncio.sleep(0) + results[task_name] = resolve_pro_api_key() + + def _ctx_with_key(key: str) -> SimpleNamespace: + headers = Headers(headers={"Blockscout-MCP-Pro-Api-Key": key}) + request = SimpleNamespace(headers=headers) + return SimpleNamespace(request_context=SimpleNamespace(request=request)) + + ctx_a = _ctx_with_key("key-for-task-a") + ctx_b = _ctx_with_key("key-for-task-b") + + await asyncio.gather( + dummy(ctx=ctx_a, task_name="a"), + dummy(ctx=ctx_b, task_name="b"), + ) + + assert results["a"] == "key-for-task-a" + assert results["b"] == "key-for-task-b" diff --git a/tests/test_pro_api_key_http_transport.py b/tests/test_pro_api_key_http_transport.py new file mode 100644 index 00000000..0b8a749f --- /dev/null +++ b/tests/test_pro_api_key_http_transport.py @@ -0,0 +1,86 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +"""Transport-level tests that prove the real FastMCP streamable-HTTP transport delivers +a client-supplied request header into the tool's ctx in the shape that +extract_client_pro_api_key_from_ctx reads. +""" + +import json + +import pytest +from mcp.server.fastmcp import Context, FastMCP +from mcp.server.transport_security import TransportSecuritySettings +from starlette.testclient import TestClient + +from blockscout_mcp_server.config import config as server_config +from blockscout_mcp_server.pro_api_key_context import pro_api_key_scope, resolve_pro_api_key + +_MCP_HEADERS = { + "Accept": "application/json, text/event-stream", + "Content-Type": "application/json", +} + + +def _build_tools_call_body(tool_name: str, arguments: dict | None = None) -> dict: + return { + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": {"name": tool_name, "arguments": arguments or {}}, + } + + +@pytest.fixture() +def mcp_app(monkeypatch): + """A throwaway FastMCP instance with a single key-echo tool and streamable-HTTP app.""" + monkeypatch.setattr(server_config, "pro_api_key_header", "Blockscout-MCP-Pro-Api-Key", raising=False) + monkeypatch.setattr(server_config, "pro_api_key", "server-key", raising=False) + + mcp = FastMCP( + name="test-key-transport", + transport_security=TransportSecuritySettings(enable_dns_rebinding_protection=False), + ) + mcp.settings.stateless_http = True + mcp.settings.json_response = True + + @mcp.tool() + @pro_api_key_scope + async def echo_resolved_key(ctx: Context) -> str: + """Return the resolved PRO API key for the current request.""" + return resolve_pro_api_key() + + return mcp.streamable_http_app() + + +def _extract_text_result(response_body: str) -> str: + """Parse the JSON-RPC result and return the first content text value.""" + data = json.loads(response_body) + return data["result"]["content"][0]["text"] + + +def test_client_key_header_reaches_tool_body(mcp_app): + """When the configured header is sent with a non-canonical casing, the tool resolves + the client-supplied key rather than the server key.""" + with TestClient(mcp_app) as client: + response = client.post( + "/mcp", + json=_build_tools_call_body("echo_resolved_key"), + headers={ + **_MCP_HEADERS, + # Non-canonical casing — exercises case-insensitive extraction + "BLOCKSCOUT-MCP-PRO-API-KEY": "my-client-key", + }, + ) + assert response.status_code == 200, f"Unexpected status: {response.status_code}, body: {response.text}" + assert _extract_text_result(response.text) == "my-client-key" + + +def test_missing_client_header_falls_back_to_server_key(mcp_app): + """When the client key header is absent the tool resolves the server key.""" + with TestClient(mcp_app) as client: + response = client.post( + "/mcp", + json=_build_tools_call_body("echo_resolved_key"), + headers=_MCP_HEADERS, + ) + assert response.status_code == 200, f"Unexpected status: {response.status_code}, body: {response.text}" + assert _extract_text_result(response.text) == "server-key" diff --git a/tests/test_web3_pool.py b/tests/test_web3_pool.py index 5a332331..761dfa08 100644 --- a/tests/test_web3_pool.py +++ b/tests/test_web3_pool.py @@ -1,15 +1,43 @@ # SPDX-License-Identifier: LicenseRef-Blockscout +import asyncio +import contextvars from unittest.mock import AsyncMock, MagicMock, patch import aiohttp import pytest from blockscout_mcp_server.config import config +from blockscout_mcp_server.pro_api_key_context import _client_key_state, _Valid from blockscout_mcp_server.web3_pool import ( AsyncHTTPProviderBlockscout, Web3Pool, ) +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _make_post_mock(captured_headers: list[dict]) -> MagicMock: + """Return a session.post mock that records the headers kwarg on each call.""" + + def _post(*args, **kwargs): + captured_headers.append(dict(kwargs.get("headers", {}))) + ctx = MagicMock() + ctx.__aenter__ = AsyncMock(return_value=ctx) + ctx.__aexit__ = AsyncMock(return_value=None) + ctx.json = AsyncMock(return_value={}) + ctx.raise_for_status = MagicMock() + return ctx + + mock = MagicMock(side_effect=_post) + return mock + + +# --------------------------------------------------------------------------- +# Existing tests (updated where needed) +# --------------------------------------------------------------------------- + @pytest.mark.asyncio async def test_web3_pool_reuses_instances(): @@ -71,6 +99,8 @@ async def test_get_merges_default_headers(): hdrs = w3.provider._request_kwargs["headers"] assert hdrs["X-Test"] == "abc" assert "User-Agent" in hdrs + # Authorization must NOT be stored on the provider + assert "Authorization" not in hdrs @pytest.mark.asyncio @@ -87,7 +117,8 @@ async def test_make_http_request_uses_headers_and_timeout(): post_ctx.raise_for_status = MagicMock() session.post = MagicMock(return_value=post_ctx) - await provider._make_http_request(session, {"jsonrpc": "2.0"}) + with patch.object(config, "pro_api_key", ""): + await provider._make_http_request(session, {"jsonrpc": "2.0"}) session.post.assert_called_once() _, kwargs = session.post.call_args @@ -100,13 +131,21 @@ async def test_make_http_request_uses_headers_and_timeout(): assert timeout.total == 10 +# --------------------------------------------------------------------------- +# Auth-specific tests (updated to new injection point) +# --------------------------------------------------------------------------- + + @pytest.mark.asyncio async def test_auth_header_in_request_headers_not_in_cache_key(): - """Provider request headers contain Authorization; pool cache key does not.""" + """Auth is injected at request time; cache key and stored provider headers contain no Authorization.""" pool = Web3Pool() mock_session = MagicMock() mock_session.closed = False api_key = "my-secret-key" + captured: list[dict] = [] + mock_session.post = _make_post_mock(captured) + with ( patch( "blockscout_mcp_server.web3_pool.ensure_chain_supported", @@ -117,14 +156,20 @@ async def test_auth_header_in_request_headers_not_in_cache_key(): patch.object(config, "pro_api_base_url", "https://api.blockscout.com"), ): w3 = await pool.get("1") + # Make an actual request so _make_http_request is exercised + await w3.provider._make_http_request(mock_session, {"jsonrpc": "2.0", "id": 1}) - # Provider request headers must include Authorization - hdrs = w3.provider._request_kwargs["headers"] - assert hdrs.get("Authorization") == f"Bearer {api_key}" + # Provider stored headers must NOT include Authorization + stored_hdrs = w3.provider._request_kwargs["headers"] + assert "Authorization" not in stored_hdrs + + # The outgoing request must carry Authorization: Bearer + assert len(captured) == 1 + assert captured[0].get("Authorization") == f"Bearer {api_key}" # No cache key in the pool should contain the Authorization value - for key in pool._pool: - _chain_id, hdr_items = key + for cache_key in pool._pool: + _chain_id, hdr_items = cache_key for hdr_name, hdr_val in hdr_items: assert hdr_name.lower() != "authorization", "Authorization found in cache key name" assert api_key not in hdr_val, "API key found in cache key value" @@ -132,12 +177,14 @@ async def test_auth_header_in_request_headers_not_in_cache_key(): @pytest.mark.asyncio async def test_changed_key_is_reapplied_on_cache_hit(): - """A changed configured key is re-applied on the next call even when the provider is cached.""" + """On a cache hit, the outgoing request reflects the currently-effective key (resolved per request).""" pool = Web3Pool() mock_session = MagicMock() mock_session.closed = False first_key = "first-token" second_key = "second-token" + captured: list[dict] = [] + mock_session.post = _make_post_mock(captured) with ( patch( @@ -149,8 +196,9 @@ async def test_changed_key_is_reapplied_on_cache_hit(): patch.object(config, "pro_api_base_url", "https://api.blockscout.com"), ): w3_first = await pool.get("1") + await w3_first.provider._make_http_request(mock_session, {"jsonrpc": "2.0", "id": 1}) - # Change the configured key and call get() again for the same chain + # Change the configured key and call get() again for the same chain (cache hit) with ( patch( "blockscout_mcp_server.web3_pool.ensure_chain_supported", @@ -161,18 +209,23 @@ async def test_changed_key_is_reapplied_on_cache_hit(): patch.object(config, "pro_api_base_url", "https://api.blockscout.com"), ): w3_second = await pool.get("1") + await w3_second.provider._make_http_request(mock_session, {"jsonrpc": "2.0", "id": 2}) # Same provider instance (cache hit) assert w3_first is w3_second - # Auth header must reflect the second (changed) key - hdrs = w3_second.provider._request_kwargs["headers"] - assert hdrs.get("Authorization") == f"Bearer {second_key}" - assert f"Bearer {first_key}" not in hdrs.get("Authorization", "") + # Provider stored headers still have no Authorization + stored_hdrs = w3_second.provider._request_kwargs["headers"] + assert "Authorization" not in stored_hdrs + + # First request carried first_key, second request carried second_key + assert len(captured) == 2 + assert captured[0].get("Authorization") == f"Bearer {first_key}" + assert captured[1].get("Authorization") == f"Bearer {second_key}" # Cache key must not contain either token - for key in pool._pool: - _chain_id, hdr_items = key + for cache_key in pool._pool: + _chain_id, hdr_items = cache_key for hdr_name, hdr_val in hdr_items: assert hdr_name.lower() != "authorization" assert first_key not in hdr_val @@ -181,12 +234,14 @@ async def test_changed_key_is_reapplied_on_cache_hit(): @pytest.mark.asyncio async def test_caller_authorization_is_sanitized(): - """Caller-supplied Authorization is replaced by config key; X-Test is preserved.""" + """Caller-supplied Authorization is stripped; only the resolver's key is emitted at request time.""" pool = Web3Pool() mock_session = MagicMock() mock_session.closed = False config_key = "config-api-key" caller_token = "caller-secret" + captured: list[dict] = [] + mock_session.post = _make_post_mock(captured) with ( patch( @@ -198,33 +253,39 @@ async def test_caller_authorization_is_sanitized(): patch.object(config, "pro_api_base_url", "https://api.blockscout.com"), ): w3 = await pool.get("1", headers={"Authorization": f"Bearer {caller_token}", "X-Test": "abc"}) + await w3.provider._make_http_request(mock_session, {"jsonrpc": "2.0", "id": 1}) # No cache key should contain the caller's token or an Authorization entry - for key in pool._pool: - _chain_id, hdr_items = key + for cache_key in pool._pool: + _chain_id, hdr_items = cache_key for hdr_name, hdr_val in hdr_items: assert hdr_name.lower() != "authorization", "Authorization found in cache key" assert caller_token not in hdr_val, "Caller token found in cache key value" - # Provider request headers must carry the config key, not the caller token - hdrs = w3.provider._request_kwargs["headers"] - assert hdrs.get("Authorization") == f"Bearer {config_key}" - assert caller_token not in hdrs.get("Authorization", "") + # Provider stored headers must NOT carry Authorization at all + stored_hdrs = w3.provider._request_kwargs["headers"] + assert "Authorization" not in stored_hdrs + assert caller_token not in str(stored_hdrs) - # Custom X-Test header must be preserved in both cache key and request headers - assert hdrs.get("X-Test") == "abc" + # Custom X-Test header must be preserved in both cache key and stored headers + assert stored_hdrs.get("X-Test") == "abc" found_x_test_in_key = False - for key in pool._pool: - _chain_id, hdr_items = key + for cache_key in pool._pool: + _chain_id, hdr_items = cache_key for hdr_name, hdr_val in hdr_items: if hdr_name == "X-Test" and hdr_val == "abc": found_x_test_in_key = True assert found_x_test_in_key, "X-Test header not found in cache key" + # The outgoing request must carry the config key, not the caller token + assert len(captured) == 1 + assert captured[0].get("Authorization") == f"Bearer {config_key}" + assert caller_token not in captured[0].get("Authorization", "") + @pytest.mark.asyncio async def test_no_key_raises_value_error(): - """get() raises ValueError immediately when no PRO API key is configured.""" + """get() raises ValueError immediately when no effective PRO API key is available.""" pool = Web3Pool() ensure_mock = AsyncMock() session_cls_mock = MagicMock() @@ -356,3 +417,147 @@ async def test_close_clears_session_and_new_get_creates_fresh_session(): assert mock_session_cls.call_count == 2 assert pool._session is second_session + + +# --------------------------------------------------------------------------- +# New tests required by Phase 5 +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_serverless_mode_client_key_only(): + """Empty server key + valid client key in ContextVar: get() does not raise, request carries client key.""" + pool = Web3Pool() + mock_session = MagicMock() + mock_session.closed = False + client_key = "client-only-key" + captured: list[dict] = [] + mock_session.post = _make_post_mock(captured) + + # Set the ContextVar to a valid client key + token = _client_key_state.set(_Valid(value=client_key)) + try: + with ( + patch( + "blockscout_mcp_server.web3_pool.ensure_chain_supported", + new_callable=AsyncMock, + ), + patch("blockscout_mcp_server.web3_pool.aiohttp.ClientSession", return_value=mock_session), + patch.object(config, "pro_api_key", ""), # empty server key + patch.object(config, "pro_api_base_url", "https://api.blockscout.com"), + ): + # Should not raise even though server key is empty + w3 = await pool.get("1") + await w3.provider._make_http_request(mock_session, {"jsonrpc": "2.0", "id": 1}) + finally: + _client_key_state.reset(token) + + assert len(captured) == 1 + assert captured[0].get("Authorization") == f"Bearer {client_key}" + + +@pytest.mark.asyncio +async def test_concurrency_no_cross_contamination(): + """Two concurrent requests to the same chain under different client keys each carry their own key.""" + pool = Web3Pool() + mock_session = MagicMock() + mock_session.closed = False + + key_a = "client-key-A" + key_b = "client-key-B" + + # captured_a / captured_b record headers from each task's request + captured_a: list[dict] = [] + captured_b: list[dict] = [] + + async def run_request_in_context(client_key: str, captured: list[dict]) -> None: + """Run a single _make_http_request inside a copy of the current context.""" + token = _client_key_state.set(_Valid(value=client_key)) + try: + # Obtain (or reuse) the pooled provider + w3 = await pool.get("1") + # Build a session mock that records to the caller's captured list + local_session = MagicMock() + local_session.post = _make_post_mock(captured) + await w3.provider._make_http_request(local_session, {"jsonrpc": "2.0", "id": 1}) + finally: + _client_key_state.reset(token) + + with ( + patch( + "blockscout_mcp_server.web3_pool.ensure_chain_supported", + new_callable=AsyncMock, + ), + patch("blockscout_mcp_server.web3_pool.aiohttp.ClientSession", return_value=mock_session), + patch.object(config, "pro_api_key", ""), # no server key — only client keys matter + patch.object(config, "pro_api_base_url", "https://api.blockscout.com"), + ): + # Pre-populate pool with a valid key so get() doesn't raise on the empty server key + seed_token = _client_key_state.set(_Valid(value=key_a)) + try: + await pool.get("1") + finally: + _client_key_state.reset(seed_token) + + # Now run two concurrent tasks under their respective client keys + ctx_a = contextvars.copy_context() + ctx_b = contextvars.copy_context() + + task_a = asyncio.ensure_future(ctx_a.run(run_request_in_context, key_a, captured_a)) + task_b = asyncio.ensure_future(ctx_b.run(run_request_in_context, key_b, captured_b)) + + await asyncio.gather(task_a, task_b) + + # Each task must have seen its own key — no cross-contamination + assert len(captured_a) == 1, "Task A did not record exactly one request" + assert len(captured_b) == 1, "Task B did not record exactly one request" + assert captured_a[0].get("Authorization") == f"Bearer {key_a}", "Task A carried wrong key" + assert captured_b[0].get("Authorization") == f"Bearer {key_b}", "Task B carried wrong key" + + +@pytest.mark.asyncio +async def test_no_fallback_on_upstream_rejection(): + """With a valid client key, an HTTP rejection propagates and no retry with the server key is issued.""" + pool = Web3Pool() + client_key = "well-formed-client-key" + server_key = "server-key-different" + captured: list[dict] = [] + + # Session.post returns an async context manager whose __aenter__ raises to simulate a 401 rejection + def _failing_post(*args, **kwargs): + captured.append(dict(kwargs.get("headers", {}))) + ctx = MagicMock() + ctx.__aenter__ = AsyncMock(side_effect=aiohttp.ClientResponseError(MagicMock(), MagicMock(), status=401)) + ctx.__aexit__ = AsyncMock(return_value=None) + return ctx + + mock_session = MagicMock() + mock_session.closed = False + mock_session.post = MagicMock(side_effect=_failing_post) + + token = _client_key_state.set(_Valid(value=client_key)) + try: + with ( + patch( + "blockscout_mcp_server.web3_pool.ensure_chain_supported", + new_callable=AsyncMock, + ), + patch("blockscout_mcp_server.web3_pool.aiohttp.ClientSession", return_value=mock_session), + patch.object(config, "pro_api_key", server_key), + patch.object(config, "pro_api_base_url", "https://api.blockscout.com"), + ): + w3 = await pool.get("1") + with pytest.raises(aiohttp.ClientResponseError) as exc_info: + await w3.provider._make_http_request(mock_session, {"jsonrpc": "2.0", "id": 1}) + finally: + _client_key_state.reset(token) + + # Error must propagate (no swallowing) + assert exc_info.value.status == 401 + + # Exactly one request was issued — no retry with the server key + assert len(captured) == 1, "Expected exactly one request (no retry)" + + # The request carried the client key, not the server key + assert captured[0].get("Authorization") == f"Bearer {client_key}" + assert server_key not in captured[0].get("Authorization", "") diff --git a/tests/tools/contract/test_fetch_and_process_contract.py b/tests/tools/contract/test_fetch_and_process_contract.py index 46932583..8a1318c8 100644 --- a/tests/tools/contract/test_fetch_and_process_contract.py +++ b/tests/tools/contract/test_fetch_and_process_contract.py @@ -5,6 +5,7 @@ from blockscout_mcp_server.cache import CachedContract from blockscout_mcp_server.config import config +from blockscout_mcp_server.pro_api_key_context import _client_key_state, _Malformed, _Valid from blockscout_mcp_server.tools.contract._shared import _fetch_and_process_contract @@ -18,6 +19,7 @@ async def test_fetch_and_process_cache_miss(mock_ctx): "constructor_args": "0x", } with ( + patch.object(config, "pro_api_key", "test_key"), patch( "blockscout_mcp_server.tools.contract._shared.contract_cache.get", new_callable=AsyncMock, @@ -51,6 +53,7 @@ async def test_fetch_and_process_cache_miss(mock_ctx): async def test_fetch_and_process_cache_hit(mock_ctx): cached = CachedContract(metadata={}, source_files={}) with ( + patch.object(config, "pro_api_key", "test_key"), patch( "blockscout_mcp_server.tools.contract._shared.contract_cache.get", new_callable=AsyncMock, @@ -78,6 +81,7 @@ async def test_process_logic_single_solidity_file(mock_ctx): "constructor_args": None, } with ( + patch.object(config, "pro_api_key", "test_key"), patch( "blockscout_mcp_server.tools.contract._shared.contract_cache.get", new_callable=AsyncMock, @@ -111,6 +115,7 @@ async def test_process_logic_multi_file_missing_main_path(mock_ctx): "constructor_args": None, } with ( + patch.object(config, "pro_api_key", "test_key"), patch( "blockscout_mcp_server.tools.contract._shared.contract_cache.get", new_callable=AsyncMock, @@ -152,6 +157,7 @@ async def test_process_logic_multi_file_and_vyper(mock_ctx): "constructor_args": None, } with ( + patch.object(config, "pro_api_key", "test_key"), patch( "blockscout_mcp_server.tools.contract._shared.contract_cache.get", new_callable=AsyncMock, @@ -190,6 +196,7 @@ async def test_process_logic_unverified_contract(mock_ctx): "proxy_type": "unknown", } with ( + patch.object(config, "pro_api_key", "test_key"), patch( "blockscout_mcp_server.tools.contract._shared.contract_cache.get", new_callable=AsyncMock, @@ -215,3 +222,117 @@ async def test_process_logic_unverified_contract(mock_ctx): assert "source_code" not in result.metadata assert "additional_sources" not in result.metadata assert mock_ctx.report_progress.await_count == 0 + + +# --------------------------------------------------------------------------- +# Phase 4: contract-metadata cache short-circuit gate tests +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_fetch_and_process_cache_hit_serverless_valid_client_key(mock_ctx): + """Cache hit with empty server key and valid client key: gate passes, cache serves data, no HTTP call.""" + cached = CachedContract(metadata={}, source_files={}) + token = _client_key_state.set(_Valid(value="client-key-xyz")) + try: + with ( + patch.object(config, "pro_api_key", ""), + patch( + "blockscout_mcp_server.tools.contract._shared.contract_cache.get", + new_callable=AsyncMock, + return_value=cached, + ) as mock_get, + patch( + "blockscout_mcp_server.tools.contract._shared.make_blockscout_request", + new_callable=AsyncMock, + ) as mock_request, + ): + result = await _fetch_and_process_contract("1", "0xAbC") + finally: + _client_key_state.reset(token) + + assert result is cached + mock_get.assert_awaited_once_with("1:0xabc") + mock_request.assert_not_called() + + +@pytest.mark.asyncio +async def test_fetch_and_process_cache_hit_malformed_key_fails_closed(mock_ctx): + """Cache hit with malformed client key: gate raises ValueError before cache is consulted.""" + cached = CachedContract(metadata={}, source_files={}) + token = _client_key_state.set(_Malformed()) + try: + with ( + patch.object(config, "pro_api_key", "server-key"), + patch( + "blockscout_mcp_server.tools.contract._shared.contract_cache.get", + new_callable=AsyncMock, + return_value=cached, + ) as mock_get, + ): + with pytest.raises(ValueError, match="malformed"): + await _fetch_and_process_contract("1", "0xAbC") + finally: + _client_key_state.reset(token) + + mock_get.assert_not_called() + + +@pytest.mark.asyncio +async def test_fetch_and_process_cache_hit_no_key_fails_closed(mock_ctx): + """Cache hit with empty server key and absent client key: gate raises not-configured error.""" + cached = CachedContract(metadata={}, source_files={}) + with ( + patch.object(config, "pro_api_key", ""), + patch( + "blockscout_mcp_server.tools.contract._shared.contract_cache.get", + new_callable=AsyncMock, + return_value=cached, + ) as mock_get, + ): + with pytest.raises(ValueError, match="BLOCKSCOUT_PRO_API_KEY"): + await _fetch_and_process_contract("1", "0xAbC") + + mock_get.assert_not_called() + + +@pytest.mark.asyncio +async def test_fetch_and_process_cache_key_stays_credential_free(mock_ctx): + """The cache key must remain '{chain_id}:{address}' — no key material mixed in.""" + api_response = { + "name": "C", + "language": "Solidity", + "source_code": "code", + "file_path": "C.sol", + "constructor_args": None, + } + token = _client_key_state.set(_Valid(value="super-secret-client-key")) + try: + with ( + patch.object(config, "pro_api_key", ""), + patch( + "blockscout_mcp_server.tools.contract._shared.contract_cache.get", + new_callable=AsyncMock, + return_value=None, + ) as mock_get, + patch( + "blockscout_mcp_server.tools.contract._shared.make_blockscout_request", + new_callable=AsyncMock, + return_value=api_response, + ), + patch( + "blockscout_mcp_server.tools.contract._shared.contract_cache.set", + new_callable=AsyncMock, + ) as mock_set, + ): + await _fetch_and_process_contract("1", "0xAbC") + finally: + _client_key_state.reset(token) + + # cache.get and cache.set must be called with the plain "{chain_id}:{address}" key + mock_get.assert_awaited_once_with("1:0xabc") + set_key_arg = mock_set.await_args.args[0] + assert set_key_arg == "1:0xabc" + # Must not contain any key material + assert "super-secret-client-key" not in set_key_arg + assert "super-secret-client-key" not in mock_get.await_args.args[0] diff --git a/tests/tools/test_common_blockscout_request_auth.py b/tests/tools/test_common_blockscout_request_auth.py new file mode 100644 index 00000000..8226448d --- /dev/null +++ b/tests/tools/test_common_blockscout_request_auth.py @@ -0,0 +1,286 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +"""Auth-matrix tests for make_blockscout_request (GET helper) in tools/common.py. + +Covers client-key precedence, serverless mode, fallback, malformed-key fail-fast, +no-fallback-on-upstream-rejection, and ContextVar propagation through +make_request_with_periodic_progress. + +These scenarios are kept separate from the broader transport/error tests in +test_common_blockscout_request.py to stay within the 500 LOC ceiling (rule 210). +""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import httpx +import pytest + +from blockscout_mcp_server.config import config +from blockscout_mcp_server.pro_api_key_context import _client_key_state, _Valid +from blockscout_mcp_server.tools.common import make_blockscout_request, make_request_with_periodic_progress + +# --------------------------------------------------------------------------- +# Shared helpers +# --------------------------------------------------------------------------- + +_DEFAULT_URL = "https://api.blockscout.com/1/api/v2/test" +_CHAIN_ID = "1" +_API_PATH = "/api/v2/test" + + +def _ok_response(url: str = _DEFAULT_URL) -> httpx.Response: + request = httpx.Request("GET", url) + return httpx.Response(200, json={"result": "ok"}, request=request) + + +def _error_response(status_code: int, url: str = _DEFAULT_URL) -> httpx.Response: + request = httpx.Request("GET", url) + return httpx.Response(status_code, content=b"Rejected", request=request) + + +class CapturingClient: + """Fake httpx.AsyncClient that records the URL and headers from .get().""" + + def __init__(self, response: httpx.Response) -> None: + self._response = response + self.get_url: str | None = None + self.get_headers: dict = {} + self.call_count = 0 + + async def __aenter__(self) -> "CapturingClient": + return self + + async def __aexit__(self, *args) -> None: + return None + + async def get(self, url: str, params=None, headers=None, **kwargs) -> httpx.Response: + self.call_count += 1 + self.get_url = url + self.get_headers = dict(headers or {}) + return self._response + + +class NeverCalledClient: + """Fake client that fails the test if any HTTP method is invoked.""" + + async def __aenter__(self) -> "NeverCalledClient": + return self + + async def __aexit__(self, *args) -> None: + return None + + async def get(self, *args, **kwargs): + raise AssertionError("HTTP client should not have been called") + + +# --------------------------------------------------------------------------- +# 1. Client key overrides server key +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_get_client_key_overrides_server_key(monkeypatch): + """GET sends the client key even when config.pro_api_key holds a different server key.""" + monkeypatch.setattr(config, "pro_api_key", "server-key") + fake_client = CapturingClient(_ok_response()) + token = _client_key_state.set(_Valid(value="client-key")) + try: + with ( + patch("blockscout_mcp_server.tools.common._create_httpx_client", return_value=fake_client), + patch("blockscout_mcp_server.tools.common.ensure_chain_supported", AsyncMock()), + ): + result = await make_blockscout_request(chain_id=_CHAIN_ID, api_path=_API_PATH) + finally: + _client_key_state.reset(token) + + assert result == {"result": "ok"} + assert fake_client.get_headers.get("Authorization") == "Bearer client-key" + + +# --------------------------------------------------------------------------- +# 2. Serverless mode: empty server key + valid client key succeeds +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_get_serverless_mode_valid_client_key(monkeypatch): + """With empty server key and valid client key, GET sends client-key Authorization and succeeds.""" + monkeypatch.setattr(config, "pro_api_key", "") + fake_client = CapturingClient(_ok_response()) + token = _client_key_state.set(_Valid(value="client-only-key")) + try: + with ( + patch("blockscout_mcp_server.tools.common._create_httpx_client", return_value=fake_client), + patch("blockscout_mcp_server.tools.common.ensure_chain_supported", AsyncMock()), + ): + result = await make_blockscout_request(chain_id=_CHAIN_ID, api_path=_API_PATH) + finally: + _client_key_state.reset(token) + + assert result == {"result": "ok"} + assert fake_client.get_headers.get("Authorization") == "Bearer client-only-key" + + +# --------------------------------------------------------------------------- +# 3. Absent client key falls back to server key +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_get_falls_back_to_server_key_when_client_key_absent(monkeypatch): + """With no ContextVar and config.pro_api_key set, GET sends the server key.""" + monkeypatch.setattr(config, "pro_api_key", "server-only-key") + fake_client = CapturingClient(_ok_response()) + with ( + patch("blockscout_mcp_server.tools.common._create_httpx_client", return_value=fake_client), + patch("blockscout_mcp_server.tools.common.ensure_chain_supported", AsyncMock()), + ): + result = await make_blockscout_request(chain_id=_CHAIN_ID, api_path=_API_PATH) + + assert result == {"result": "ok"} + assert fake_client.get_headers.get("Authorization") == "Bearer server-only-key" + + +# --------------------------------------------------------------------------- +# 4. Malformed client key fails before any HTTP call +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_get_malformed_client_key_raises_before_http_call(monkeypatch): + """With a malformed client key in the ContextVar, GET raises ValueError before any HTTP call.""" + from blockscout_mcp_server.pro_api_key_context import _Malformed + + monkeypatch.setattr(config, "pro_api_key", "server-key") + token = _client_key_state.set(_Malformed()) + try: + with ( + patch("blockscout_mcp_server.tools.common._create_httpx_client", return_value=NeverCalledClient()), + patch("blockscout_mcp_server.tools.common.ensure_chain_supported", AsyncMock()), + ): + with pytest.raises(ValueError, match="malformed"): + await make_blockscout_request(chain_id=_CHAIN_ID, api_path=_API_PATH) + finally: + _client_key_state.reset(token) + + +# --------------------------------------------------------------------------- +# 5. No fallback on upstream-rejected key (well-formed client key, upstream 401) +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_get_no_fallback_on_upstream_rejection(monkeypatch): + """GET makes exactly one request with the client key and propagates HTTPStatusError on upstream 401.""" + monkeypatch.setattr(config, "pro_api_key", "server-key") + + attempt_count = {"n": 0} + captured_headers: list[dict] = [] + + class _RejectingClient: + async def __aenter__(self): + return self + + async def __aexit__(self, *args): + return None + + async def get(self, url, params=None, headers=None, **kwargs): + attempt_count["n"] += 1 + captured_headers.append(dict(headers or {})) + request = httpx.Request("GET", url) + return httpx.Response(401, content=b"Unauthorized", request=request) + + token = _client_key_state.set(_Valid(value="well-formed-client-key")) + try: + with ( + patch("blockscout_mcp_server.tools.common._create_httpx_client", return_value=_RejectingClient()), + patch("blockscout_mcp_server.tools.common.ensure_chain_supported", AsyncMock()), + ): + with pytest.raises(httpx.HTTPStatusError): + await make_blockscout_request(chain_id=_CHAIN_ID, api_path=_API_PATH) + finally: + _client_key_state.reset(token) + + # Must make exactly one call, with the client key (not a second attempt with the server key) + assert attempt_count["n"] == 1 + assert captured_headers[0].get("Authorization") == "Bearer well-formed-client-key" + + +# --------------------------------------------------------------------------- +# 6. Both keys absent raises not-configured ValueError +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_get_raises_not_configured_when_both_keys_absent(monkeypatch): + """With no client key and empty server key, GET raises the not-configured ValueError.""" + monkeypatch.setattr(config, "pro_api_key", "") + with ( + patch("blockscout_mcp_server.tools.common._create_httpx_client", return_value=NeverCalledClient()), + patch("blockscout_mcp_server.tools.common.ensure_chain_supported", AsyncMock()), + ): + with pytest.raises(ValueError, match="BLOCKSCOUT_PRO_API_KEY"): + await make_blockscout_request(chain_id=_CHAIN_ID, api_path=_API_PATH) + + +# --------------------------------------------------------------------------- +# 7. ContextVar propagates into make_request_with_periodic_progress child task +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_get_context_var_propagates_into_periodic_progress_task(monkeypatch): + """A key set in ContextVar is observed by the request function spawned via make_request_with_periodic_progress.""" + monkeypatch.setattr(config, "pro_api_key", "server-key") + fake_client = CapturingClient(_ok_response()) + token = _client_key_state.set(_Valid(value="propagated-client-key")) + try: + with ( + patch("blockscout_mcp_server.tools.common._create_httpx_client", return_value=fake_client), + patch("blockscout_mcp_server.tools.common.ensure_chain_supported", AsyncMock()), + ): + mock_ctx = MagicMock() + mock_ctx.report_progress = AsyncMock() + + result = await make_request_with_periodic_progress( + ctx=mock_ctx, + request_function=make_blockscout_request, + request_args={"chain_id": _CHAIN_ID, "api_path": _API_PATH}, + total_duration_hint=30.0, + ) + finally: + _client_key_state.reset(token) + + assert result == {"result": "ok"} + # Progress beats must have been emitted along the periodic-progress path. + mock_ctx.report_progress.assert_awaited() + # The key must have been visible inside the spawned child task + assert fake_client.get_headers.get("Authorization") == "Bearer propagated-client-key" + + +# --------------------------------------------------------------------------- +# 8. Secret not in exception message +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_get_malformed_key_not_in_exception_message(monkeypatch): + """The ValueError for a malformed key must not contain the raw submitted key value.""" + from blockscout_mcp_server.pro_api_key_context import _Malformed + + monkeypatch.setattr(config, "pro_api_key", "server-key") + # Simulate a raw key that was submitted but failed validation + # We set _Malformed state (since _normalize_key already stripped the value) + token = _client_key_state.set(_Malformed()) + try: + with patch("blockscout_mcp_server.tools.common._create_httpx_client", return_value=NeverCalledClient()): + with pytest.raises(ValueError) as exc_info: + await make_blockscout_request(chain_id=_CHAIN_ID, api_path=_API_PATH) + finally: + _client_key_state.reset(token) + + # The exception message must not leak any key material + error_message = str(exc_info.value) + # The _Malformed state carries no raw value, so this verifies the message + # describes the error without echoing raw input. + assert "Bearer" not in error_message + assert "server-key" not in error_message diff --git a/tests/tools/test_common_metadata.py b/tests/tools/test_common_metadata.py index 718ce540..1d58f56f 100644 --- a/tests/tools/test_common_metadata.py +++ b/tests/tools/test_common_metadata.py @@ -12,6 +12,7 @@ from blockscout_mcp_server.config import config from blockscout_mcp_server.constants import SERVER_VERSION +from blockscout_mcp_server.pro_api_key_context import _client_key_state, _Valid from blockscout_mcp_server.tools.common import ( CreditsExhaustedError, _pro_api_headers, @@ -370,6 +371,119 @@ async def test_make_blockscout_request_sends_pro_api_key_to_pro_api_host(monkeyp assert sent_headers.get("Accept") == "application/json" +# --------------------------------------------------------------------------- +# Phase 4: _pro_api_headers() and make_metadata_request() with resolved key +# --------------------------------------------------------------------------- + + +def test_pro_api_headers_client_key_overrides_server_key(monkeypatch): + """_pro_api_headers() uses the client key when the ContextVar holds a valid key.""" + monkeypatch.setattr(config, "pro_api_key", "server-key") + token = _client_key_state.set(_Valid(value="client-key")) + try: + headers = _pro_api_headers() + finally: + _client_key_state.reset(token) + assert headers["Authorization"] == "Bearer client-key" + + +def test_pro_api_headers_serverless_client_key(monkeypatch): + """_pro_api_headers() includes client key Authorization even with empty server key.""" + monkeypatch.setattr(config, "pro_api_key", "") + token = _client_key_state.set(_Valid(value="client-key-only")) + try: + headers = _pro_api_headers() + finally: + _client_key_state.reset(token) + assert headers["Authorization"] == "Bearer client-key-only" + + +def test_pro_api_headers_falls_back_to_server_key_when_client_absent(monkeypatch): + """_pro_api_headers() returns the server key when no client key is in the ContextVar.""" + monkeypatch.setattr(config, "pro_api_key", "server-only-key") + headers = _pro_api_headers() + assert headers["Authorization"] == "Bearer server-only-key" + + +def test_pro_api_headers_omits_authorization_when_both_absent(monkeypatch): + """_pro_api_headers() omits Authorization when both server key and ContextVar are absent.""" + monkeypatch.setattr(config, "pro_api_key", "") + headers = _pro_api_headers() + assert "Authorization" not in headers + + +@pytest.mark.asyncio +async def test_make_metadata_request_serverless_valid_client_key(monkeypatch): + """With empty server key and valid client key in ContextVar, make_metadata_request succeeds.""" + monkeypatch.setattr(config, "pro_api_key", "") + api_path = "/api/v1/metadata/address" + + fake_client = CapturingAsyncClient(_ok_response(config.pro_api_base_url + api_path)) + token = _client_key_state.set(_Valid(value="client-only-key")) + try: + with patch("blockscout_mcp_server.tools.common._create_httpx_client", return_value=fake_client): + result = await make_metadata_request(api_path) + finally: + _client_key_state.reset(token) + + assert result == {"result": "ok"} + sent_headers = (fake_client.get_kwargs or {}).get("headers", {}) + assert sent_headers.get("Authorization") == "Bearer client-only-key" + + +@pytest.mark.asyncio +async def test_make_metadata_request_raises_malformed_key_before_network(monkeypatch): + """make_metadata_request raises ValueError for malformed key before any HTTP call.""" + from blockscout_mcp_server.pro_api_key_context import _Malformed + + monkeypatch.setattr(config, "pro_api_key", "server-key") + + def _fail(*args, **kwargs): + raise AssertionError("No HTTP client should be created for a malformed key") + + token = _client_key_state.set(_Malformed()) + try: + with patch("blockscout_mcp_server.tools.common._create_httpx_client", _fail): + with pytest.raises(ValueError, match="malformed"): + await make_metadata_request("/api/v1/metadata/address") + finally: + _client_key_state.reset(token) + + +@pytest.mark.asyncio +async def test_make_metadata_request_no_fallback_on_upstream_rejection(monkeypatch): + """With a valid client key and server key both set, an upstream 401 raises HTTPStatusError without retry.""" + monkeypatch.setattr(config, "pro_api_key", "server-key") + api_path = "/api/v1/metadata/address" + + attempt_count = {"n": 0} + captured_headers: list[dict] = [] + + class _RejectingClient: + async def __aenter__(self): + return self + + async def __aexit__(self, *args): + return None + + async def get(self, url, **kwargs): + attempt_count["n"] += 1 + captured_headers.append(dict(kwargs.get("headers") or {})) + request = httpx.Request("GET", url) + return httpx.Response(401, content=b"Unauthorized", request=request) + + token = _client_key_state.set(_Valid(value="client-key")) + try: + with patch("blockscout_mcp_server.tools.common._create_httpx_client", return_value=_RejectingClient()): + with pytest.raises(httpx.HTTPStatusError): + await make_metadata_request(api_path) + finally: + _client_key_state.reset(token) + + assert attempt_count["n"] == 1 + assert captured_headers[0].get("Authorization") == "Bearer client-key" + + # --------------------------------------------------------------------------- # CreditsExhaustedError: metadata 402 → distinct error, no retry # --------------------------------------------------------------------------- diff --git a/tests/tools/test_common_post_request.py b/tests/tools/test_common_post_request.py index 2a2ad7e9..97930e7e 100644 --- a/tests/tools/test_common_post_request.py +++ b/tests/tools/test_common_post_request.py @@ -5,6 +5,7 @@ import pytest from blockscout_mcp_server.config import config +from blockscout_mcp_server.pro_api_key_context import _client_key_state, _Valid from blockscout_mcp_server.tools.common import CreditsExhaustedError, make_blockscout_post_request @@ -241,6 +242,190 @@ async def post(self, url, json, params, headers=None, **kwargs): assert captured["headers"].get("Accept") == "application/json" +# --------------------------------------------------------------------------- +# Phase 4: POST auth matrix (client-key precedence, serverless mode, etc.) +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_post_client_key_overrides_server_key(monkeypatch): + """POST sends the client key even when config.pro_api_key holds a different server key.""" + monkeypatch.setattr(config, "pro_api_key", "server-key") + captured: dict = {} + + class CapturingPostClient: + async def __aenter__(self): + return self + + async def __aexit__(self, *args): + return False + + async def post(self, url, json, params, headers=None, **kwargs): + captured["headers"] = dict(headers or {}) + request = httpx.Request("POST", url) + return httpx.Response(200, json={"result": "ok"}, request=request) + + token = _client_key_state.set(_Valid(value="client-key")) + try: + with ( + patch("blockscout_mcp_server.tools.common._create_httpx_client", return_value=CapturingPostClient()), + patch("blockscout_mcp_server.tools.common.ensure_chain_supported", AsyncMock()), + ): + result = await make_blockscout_post_request("1", "/json-rpc", {"x": 1}) + finally: + _client_key_state.reset(token) + + assert result == {"result": "ok"} + assert captured["headers"].get("Authorization") == "Bearer client-key" + + +@pytest.mark.asyncio +async def test_post_serverless_mode_valid_client_key(monkeypatch): + """With empty server key and valid client key in ContextVar, POST sends client-key Authorization and succeeds.""" + monkeypatch.setattr(config, "pro_api_key", "") + captured: dict = {} + + class CapturingPostClient: + async def __aenter__(self): + return self + + async def __aexit__(self, *args): + return False + + async def post(self, url, json, params, headers=None, **kwargs): + captured["headers"] = dict(headers or {}) + request = httpx.Request("POST", url) + return httpx.Response(200, json={"result": "ok"}, request=request) + + token = _client_key_state.set(_Valid(value="client-only-key")) + try: + with ( + patch("blockscout_mcp_server.tools.common._create_httpx_client", return_value=CapturingPostClient()), + patch("blockscout_mcp_server.tools.common.ensure_chain_supported", AsyncMock()), + ): + result = await make_blockscout_post_request("1", "/json-rpc", {"x": 1}) + finally: + _client_key_state.reset(token) + + assert result == {"result": "ok"} + assert captured["headers"].get("Authorization") == "Bearer client-only-key" + + +@pytest.mark.asyncio +async def test_post_falls_back_to_server_key_when_client_absent(monkeypatch): + """With no ContextVar and config.pro_api_key set, POST sends the server key.""" + monkeypatch.setattr(config, "pro_api_key", "server-only-key") + captured: dict = {} + + class CapturingPostClient: + async def __aenter__(self): + return self + + async def __aexit__(self, *args): + return False + + async def post(self, url, json, params, headers=None, **kwargs): + captured["headers"] = dict(headers or {}) + request = httpx.Request("POST", url) + return httpx.Response(200, json={"result": "ok"}, request=request) + + with ( + patch("blockscout_mcp_server.tools.common._create_httpx_client", return_value=CapturingPostClient()), + patch("blockscout_mcp_server.tools.common.ensure_chain_supported", AsyncMock()), + ): + result = await make_blockscout_post_request("1", "/json-rpc", {"x": 1}) + + assert result == {"result": "ok"} + assert captured["headers"].get("Authorization") == "Bearer server-only-key" + + +@pytest.mark.asyncio +async def test_post_malformed_client_key_raises_before_http_call(monkeypatch): + """With a malformed client key in the ContextVar, POST raises ValueError before any HTTP call.""" + from blockscout_mcp_server.pro_api_key_context import _Malformed + + monkeypatch.setattr(config, "pro_api_key", "server-key") + + class NeverCalledPostClient: + async def __aenter__(self): + return self + + async def __aexit__(self, *args): + return False + + async def post(self, *args, **kwargs): + raise AssertionError("HTTP client should not have been called for a malformed key") + + token = _client_key_state.set(_Malformed()) + try: + with ( + patch("blockscout_mcp_server.tools.common._create_httpx_client", return_value=NeverCalledPostClient()), + patch("blockscout_mcp_server.tools.common.ensure_chain_supported", AsyncMock()), + ): + with pytest.raises(ValueError, match="malformed"): + await make_blockscout_post_request("1", "/json-rpc", {"x": 1}) + finally: + _client_key_state.reset(token) + + +@pytest.mark.asyncio +async def test_post_no_fallback_on_upstream_rejection(monkeypatch): + """POST makes exactly one request with the client key and propagates HTTPStatusError on upstream 401.""" + monkeypatch.setattr(config, "pro_api_key", "server-key") + attempt_count = {"n": 0} + captured_headers: list[dict] = [] + + class _RejectingPostClient: + async def __aenter__(self): + return self + + async def __aexit__(self, *args): + return False + + async def post(self, url, json, params, headers=None, **kwargs): + attempt_count["n"] += 1 + captured_headers.append(dict(headers or {})) + request = httpx.Request("POST", url) + return httpx.Response(401, content=b"Unauthorized", request=request) + + token = _client_key_state.set(_Valid(value="well-formed-client-key")) + try: + with ( + patch("blockscout_mcp_server.tools.common._create_httpx_client", return_value=_RejectingPostClient()), + patch("blockscout_mcp_server.tools.common.ensure_chain_supported", AsyncMock()), + ): + with pytest.raises(httpx.HTTPStatusError): + await make_blockscout_post_request("1", "/json-rpc", {"x": 1}) + finally: + _client_key_state.reset(token) + + assert attempt_count["n"] == 1 + assert captured_headers[0].get("Authorization") == "Bearer well-formed-client-key" + + +@pytest.mark.asyncio +async def test_post_raises_not_configured_when_both_keys_absent(monkeypatch): + """With no client key and empty server key, POST raises the not-configured ValueError.""" + monkeypatch.setattr(config, "pro_api_key", "") + + class NeverCalledPostClient: + async def __aenter__(self): + return self + + async def __aexit__(self, *args): + return False + + async def post(self, *args, **kwargs): + raise AssertionError("HTTP client should not have been called when keys are absent") + + with ( + patch("blockscout_mcp_server.tools.common._create_httpx_client", return_value=NeverCalledPostClient()), + patch("blockscout_mcp_server.tools.common.ensure_chain_supported", AsyncMock()), + ): + with pytest.raises(ValueError, match="BLOCKSCOUT_PRO_API_KEY"): + await make_blockscout_post_request("1", "/json-rpc", {"x": 1}) + + # --------------------------------------------------------------------------- # CreditsExhaustedError: POST 402 → distinct error, no retry # --------------------------------------------------------------------------- diff --git a/tests/tools/test_decorators.py b/tests/tools/test_decorators.py index dd0e74b0..15febbf5 100644 --- a/tests/tools/test_decorators.py +++ b/tests/tools/test_decorators.py @@ -8,12 +8,16 @@ import pytest from mcp.server.fastmcp import Context from mcp.types import RequestParams +from starlette.datastructures import Headers +from blockscout_mcp_server.api.dependencies import MockCtx from blockscout_mcp_server.client_meta import ( UNDEFINED_CLIENT_NAME, UNDEFINED_CLIENT_VERSION, UNKNOWN_PROTOCOL_VERSION, ) +from blockscout_mcp_server.config import config as server_config +from blockscout_mcp_server.pro_api_key_context import pro_api_key_scope, resolve_pro_api_key from blockscout_mcp_server.tools.decorators import log_tool_invocation @@ -185,3 +189,95 @@ async def dummy_tool(a: int, ctx: Context) -> int: log_text = caplog.text assert "Tool invoked: dummy_tool" in log_text assert "Meta:" not in log_text + + +# --------------------------------------------------------------------------- +# pro_api_key_scope decorator tests +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_pro_api_key_scope_mcp_context_resolves_client_key(monkeypatch, mock_ctx: Context) -> None: + """Invoking a tool stacked with log_tool_invocation + pro_api_key_scope and an MCP-like + context carrying the client-key header causes resolve_pro_api_key() to return the + client key during the tool body, and the ContextVar is reset to its default afterward.""" + monkeypatch.setattr(server_config, "pro_api_key_header", "Blockscout-MCP-Pro-Api-Key", raising=False) + monkeypatch.setattr(server_config, "pro_api_key", "server-key", raising=False) + + client_key_during_call = None + + @log_tool_invocation + @pro_api_key_scope + async def dummy_tool(a: int, ctx: Context) -> int: + nonlocal client_key_during_call + client_key_during_call = resolve_pro_api_key() + return a + + # Build a real Starlette Headers object with a non-canonical header casing + headers = Headers(headers={"BLOCKSCOUT-MCP-PRO-API-KEY": "my-client-secret"}) + mock_ctx.call_source = "mcp" + mock_ctx.request_context = SimpleNamespace(request=SimpleNamespace(headers=headers)) + mock_ctx.session = None + + await dummy_tool(42, ctx=mock_ctx) + + # The client key was resolved inside the tool body + assert client_key_during_call == "my-client-secret" + + # After the call the ContextVar is reset — resolve_pro_api_key falls back to server key + assert resolve_pro_api_key() == "server-key" + + +@pytest.mark.asyncio +async def test_pro_api_key_scope_rest_context_ignores_client_key(monkeypatch) -> None: + """A REST MockCtx call ignores the client key header and resolves the server key.""" + monkeypatch.setattr(server_config, "pro_api_key_header", "Blockscout-MCP-Pro-Api-Key", raising=False) + monkeypatch.setattr(server_config, "pro_api_key", "server-key", raising=False) + + resolved_key = None + + @log_tool_invocation + @pro_api_key_scope + async def dummy_tool(a: int, ctx) -> int: # type: ignore[no-untyped-def] + nonlocal resolved_key + resolved_key = resolve_pro_api_key() + return a + + # Build a REST-style MockCtx that carries the header — it must be ignored + rest_ctx = MockCtx() + # Attach a fake request with the client-key header on the wrapper + headers = Headers(headers={"Blockscout-MCP-Pro-Api-Key": "client-secret"}) + rest_ctx.request_context = SimpleNamespace(request=SimpleNamespace(headers=headers)) + + await dummy_tool(1, ctx=rest_ctx) + + assert resolved_key == "server-key" + + +@pytest.mark.asyncio +async def test_pro_api_key_never_appears_in_logs( + monkeypatch, caplog: pytest.LogCaptureFixture, mock_ctx: Context +) -> None: + """The client-key value must not appear in any log output from log_tool_invocation + or any other logger captured during the invocation.""" + monkeypatch.setattr(server_config, "pro_api_key_header", "Blockscout-MCP-Pro-Api-Key", raising=False) + caplog.set_level(logging.DEBUG) + + client_key = "super-secret-key-xyz" + + @log_tool_invocation + @pro_api_key_scope + async def dummy_tool(a: int, ctx: Context) -> int: + return a + + headers = Headers(headers={"Blockscout-MCP-Pro-Api-Key": client_key}) + mock_ctx.call_source = "mcp" + mock_ctx.request_context = SimpleNamespace(request=SimpleNamespace(headers=headers)) + mock_ctx.session = None + + await dummy_tool(7, ctx=mock_ctx) + + # Case-insensitive: catch a leaked header name/value regardless of logger casing. + logged_text = caplog.text.lower() + assert client_key.lower() not in logged_text + assert "blockscout-mcp-pro-api-key" not in logged_text