From ba3f02db3a5f33692168c38cba8faeb5716ec20c Mon Sep 17 00:00:00 2001 From: Alexander Kolotov Date: Wed, 13 Aug 2025 04:01:40 +0000 Subject: [PATCH 01/13] Introduction of tracking tool calls through mixpanel --- .env.example | 5 + AGENTS.md | 13 ++ Dockerfile | 2 + SPEC.md | 30 ++++ blockscout_mcp_server/__init__.py | 2 +- blockscout_mcp_server/analytics.py | 177 ++++++++++++++++++++++ blockscout_mcp_server/api/dependencies.py | 18 ++- blockscout_mcp_server/api/routes.py | 42 ++--- blockscout_mcp_server/client_meta.py | 63 ++++++++ blockscout_mcp_server/config.py | 11 +- blockscout_mcp_server/server.py | 3 + blockscout_mcp_server/tools/decorators.py | 28 ++-- pyproject.toml | 3 +- pytest.ini | 2 +- tests/test_analytics.py | 76 ++++++++++ tests/test_analytics_helpers.py | 40 +++++ tests/test_analytics_source.py | 23 +++ tests/test_client_meta.py | 40 +++++ tests/tools/test_decorators.py | 30 ++++ 19 files changed, 567 insertions(+), 41 deletions(-) create mode 100644 blockscout_mcp_server/analytics.py create mode 100644 blockscout_mcp_server/client_meta.py create mode 100644 tests/test_analytics.py create mode 100644 tests/test_analytics_helpers.py create mode 100644 tests/test_analytics_source.py create mode 100644 tests/test_client_meta.py diff --git a/.env.example b/.env.example index 27b2055..a84e942 100644 --- a/.env.example +++ b/.env.example @@ -36,3 +36,8 @@ BLOCKSCOUT_RPC_POOL_PER_HOST=50 # The server version is appended automatically. BLOCKSCOUT_MCP_USER_AGENT="Blockscout MCP" +# Optional Mixpanel analytics (HTTP mode only). Set token to enable; leave empty to disable. +# Use API host for regional endpoints (e.g., EU). No tracking occurs in stdio mode. +BLOCKSCOUT_MIXPANEL_TOKEN="" +BLOCKSCOUT_MIXPANEL_API_HOST="" + diff --git a/AGENTS.md b/AGENTS.md index fbee1af..e3bd1d7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,6 +19,8 @@ mcp-server/ │ ├── config.py # Configuration management (e.g., API keys, timeouts, cache settings) │ ├── constants.py # Centralized constants used throughout the application, including data truncation limits │ ├── logging_utils.py # Logging utilities for production-ready log formatting +│ ├── analytics.py # Centralized Mixpanel analytics for tool invocations (HTTP mode only) +│ ├── client_meta.py # Shared client metadata extraction helpers and defaults │ ├── 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 @@ -206,6 +208,17 @@ mcp-server/ * **`logging_utils.py`**: * Provides utilities for configuring production-ready logging. * Contains the `replace_rich_handlers_with_standard()` function that eliminates multi-line Rich formatting from MCP SDK logs. + * **`analytics.py`**: + * Centralized Mixpanel analytics for MCP tool invocations. + * Enabled only in HTTP mode when `BLOCKSCOUT_MIXPANEL_TOKEN` is set. + * Generates deterministic `distinct_id` based on client IP, name, and version fingerprint. + * Tracks tool invocations with client metadata, protocol version, and call source (MCP vs REST). + * Includes IP geolocation metadata for Mixpanel and graceful error handling to avoid breaking tool execution. + * **`client_meta.py`**: + * Shared utilities for extracting client metadata (name, version, protocol, user_agent) from MCP Context. + * 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. * **`cache.py`**: * Encapsulates in-memory caching of chain data with TTL management. * **`web3_pool.py`**: diff --git a/Dockerfile b/Dockerfile index cc889a2..cd033c3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -32,5 +32,7 @@ ENV BLOCKSCOUT_ADVANCED_FILTERS_PAGE_SIZE="10" ENV BLOCKSCOUT_RPC_REQUEST_TIMEOUT="60.0" ENV BLOCKSCOUT_RPC_POOL_PER_HOST="50" ENV BLOCKSCOUT_MCP_USER_AGENT="Blockscout MCP" +ENV BLOCKSCOUT_MIXPANEL_TOKEN="" +ENV BLOCKSCOUT_MIXPANEL_API_HOST="" CMD ["python", "-m", "blockscout_mcp_server"] diff --git a/SPEC.md b/SPEC.md index ca5322d..ca0dc49 100644 --- a/SPEC.md +++ b/SPEC.md @@ -493,8 +493,38 @@ Implemented via the `@log_tool_invocation` decorator, these logs capture: - The arguments provided to the tool. - The identity of the MCP client that initiated the call, including its **name**, **version**, and the **MCP protocol version** it is using. +If the client name cannot be determined from the MCP session parameters, the server falls back to the HTTP `User-Agent` header as the client identifier. + This provides a clear audit trail, helping to diagnose issues that may be specific to certain client versions or protocol implementations. For stateless calls, such as those from the REST API where no client is present, this information is gracefully omitted. +#### 3. Mixpanel Analytics for Tool Invocation + +To gain insight into tool usage patterns, the server can optionally report tool invocations to Mixpanel. + +- Activation (opt-in only): + - Enabled exclusively in HTTP modes (MCP-over-HTTP and REST). + - Requires `BLOCKSCOUT_MIXPANEL_TOKEN` to be set; otherwise analytics are disabled. + +- Integration point: + - Tracking is centralized in `blockscout_mcp_server/analytics.py` and invoked from the shared `@log_tool_invocation` decorator so every tool is tracked consistently without altering tool implementations. + +- Tracked properties (per event): + - Client IP address derived from the HTTP request, preferring proxy headers when present: `X-Forwarded-For` (first value), then `X-Real-IP`, otherwise connection `client.host`. + - MCP client name (or the HTTP `User-Agent` when the client name is unavailable). + - MCP client version. + - MCP protocol version. + - Tool arguments (currently sent as-is, without truncation). + - Call source: whether the tool was invoked by MCP or via the REST API. + +- Anonymous identity (distinct_id) (as per Mixpanel's [documentation](https://docs.mixpanel.com/docs/tracking-methods/id-management/identifying-users-simplified#server-side-identity-management)): + - A stable `distinct_id` is generated to anonymously identify unique users. + - The fingerprint is the concatenation of: namespace URL (`"https://blockscout.com/mcp/"`), client IP, client name, and client version. + - This provides stable identification even when multiple clients share the same name/version (e.g., Claude Desktop), because their IPs differ. + +- REST API support and source attribution: + - The REST context mock is extended with a request context wrapper so analytics can extract IP and headers consistently (see `blockscout_mcp_server/api/dependencies.py`). + - A `call_source` field is introduced on the REST mock context and set to `"rest"`, allowing analytics to reliably distinguish REST API calls from MCP tool calls without coupling to specific URL paths. + ### Smart Contract Interaction Tools This server exposes a tool for on-chain smart contract read-only state access. It uses the JSON-RPC `eth_call` semantics under the hood and aligns with the standardized `ToolResponse` model. diff --git a/blockscout_mcp_server/__init__.py b/blockscout_mcp_server/__init__.py index 1a95f49..82eb011 100644 --- a/blockscout_mcp_server/__init__.py +++ b/blockscout_mcp_server/__init__.py @@ -1,3 +1,3 @@ """Blockscout MCP Server package.""" -__version__ = "0.7.0" +__version__ = "0.8.0-dev" diff --git a/blockscout_mcp_server/analytics.py b/blockscout_mcp_server/analytics.py new file mode 100644 index 0000000..a9984f1 --- /dev/null +++ b/blockscout_mcp_server/analytics.py @@ -0,0 +1,177 @@ +"""Centralized Mixpanel analytics for MCP tool invocations. + +Tracking is enabled only when: +- BLOCKSCOUT_MIXPANEL_TOKEN is set, and +- server runs in HTTP mode (set via set_http_mode(True)). + +Events are emitted via Mixpanel with a deterministic distinct_id based on a +connection fingerprint composed of client IP, client name, and client version. +""" + +from __future__ import annotations + +import logging +import uuid +from typing import Any + +try: + # Import lazily; tests will mock this + from mixpanel import Consumer, Mixpanel +except Exception: # pragma: no cover - import errors covered by no-op behavior in tests + Consumer = object # type: ignore[assignment] + Mixpanel = object # type: ignore[assignment] + +from blockscout_mcp_server.client_meta import ClientMeta, extract_client_meta_from_ctx +from blockscout_mcp_server.config import config + +logger = logging.getLogger(__name__) + + +_is_http_mode_enabled: bool = False +_mp_client: Any | None = None + + +def set_http_mode(is_http: bool) -> None: + """Enable or disable HTTP mode for analytics gating.""" + global _is_http_mode_enabled + _is_http_mode_enabled = bool(is_http) + # Log enablement status once at startup (HTTP path only) + if _is_http_mode_enabled: + token = getattr(config, "mixpanel_token", "") + if token: + # Best-effort initialize client to validate configuration + _ = _get_mixpanel_client() + api_host = getattr(config, "mixpanel_api_host", "") or "default" + logger.info("Mixpanel analytics enabled (api_host=%s)", api_host) + else: + logger.debug("Mixpanel analytics not enabled: BLOCKSCOUT_MIXPANEL_TOKEN is not set") + + +def _get_mixpanel_client() -> Any | None: + """Return a singleton Mixpanel client if token is configured.""" + global _mp_client + if _mp_client is not None: + return _mp_client + token = getattr(config, "mixpanel_token", "") + if not token: + return None + try: + api_host = getattr(config, "mixpanel_api_host", "") + if api_host: + consumer = Consumer(api_host=api_host) + _mp_client = Mixpanel(token, consumer=consumer) + else: + _mp_client = Mixpanel(token) + return _mp_client + except Exception as exc: # pragma: no cover - defensive + logger.debug("Failed to initialize Mixpanel client: %s", exc) + return None + + +def _extract_request_ip(ctx: Any) -> str: + """Extract client IP address from context if possible.""" + ip = "" + try: + request = getattr(getattr(ctx, "request_context", None), "request", None) + if request is not None: + headers = request.headers or {} + # Prefer proxy-forwarded headers + xff = headers.get("x-forwarded-for") or headers.get("X-Forwarded-For") + if xff: + # left-most IP per standard + ip = xff.split(",")[0].strip() + elif headers.get("x-real-ip") or headers.get("X-Real-IP"): + ip = headers.get("x-real-ip") or headers.get("X-Real-IP") or "" + else: + client = getattr(request, "client", None) + if client and getattr(client, "host", None): + ip = client.host + except Exception: # pragma: no cover - tolerate all shapes + pass + return ip + + +def _build_distinct_id(ip: str, client_name: str, client_version: str) -> str: + # User-Agent is merged into client_name in extract_client_meta_from_ctx when name is unavailable. + # Therefore composite requires only ip, client_name and client_version for a stable fingerprint. + composite = "|".join([ip or "", client_name or "", client_version or ""]) + return str(uuid.uuid5(uuid.NAMESPACE_URL, "https://blockscout.com/mcp/" + composite)) + + +def _determine_call_source(ctx: Any) -> str: + """Return 'mcp' for MCP calls, 'rest' for REST API, else 'unknown'. + + Priority: + 1) Explicit marker set by caller (e.g., REST mock context) via `call_source`. + 2) Default to 'mcp' when no explicit marker is present (applies to MCP-over-HTTP). + """ + try: + explicit = getattr(ctx, "call_source", None) + if isinstance(explicit, str) and explicit: + return explicit + # No explicit marker: treat as MCP (covers MCP-over-HTTP) + return "mcp" + except Exception: # pragma: no cover + pass + return "unknown" + + +def track_tool_invocation( + ctx: Any, + tool_name: str, + tool_args: dict[str, Any], + client_meta: ClientMeta | None = None, +) -> None: + """Track a tool invocation in Mixpanel, if enabled and in HTTP mode.""" + if not _is_http_mode_enabled: + return + mp = _get_mixpanel_client() + if mp is None: + return + + try: + ip = _extract_request_ip(ctx) + + # Prefer provided client metadata from the decorator; otherwise, fall back to context + if client_meta is not None: + client_name = client_meta.name + client_version = client_meta.version + protocol_version = client_meta.protocol + user_agent = client_meta.user_agent + else: + meta = extract_client_meta_from_ctx(ctx) + client_name = meta.name + client_version = meta.version + protocol_version = meta.protocol + user_agent = meta.user_agent + + distinct_id = _build_distinct_id(ip, client_name, client_version) + + properties: dict[str, Any] = { + "ip": ip, + "client_name": client_name, + "client_version": client_version, + "user_agent": user_agent, + "tool_args": tool_args, + "protocol_version": protocol_version, + "source": _determine_call_source(ctx), + } + + # TODO: Remove this log after validating Mixpanel analytics end-to-end + logger.info( + "Mixpanel event prepared: distinct_id=%s tool=%s properties=%s", + distinct_id, + tool_name, + properties, + ) + + meta = {"ip": ip} if ip else None + # Mixpanel Python SDK allows meta for IP geolocation mapping + if meta is not None: + mp.track(distinct_id, tool_name, properties, meta=meta) # type: ignore[call-arg] + else: + mp.track(distinct_id, tool_name, properties) + except Exception as exc: # pragma: no cover - do not break tool flow + logger.debug("Mixpanel tracking failed for %s: %s", tool_name, exc) + + diff --git a/blockscout_mcp_server/api/dependencies.py b/blockscout_mcp_server/api/dependencies.py index 2b76474..18711e0 100644 --- a/blockscout_mcp_server/api/dependencies.py +++ b/blockscout_mcp_server/api/dependencies.py @@ -1,14 +1,28 @@ """Dependencies for the REST API, such as mock context providers.""" +class _RequestContextWrapper: + """Lightweight wrapper to mimic MCP's request_context shape for analytics.""" + + def __init__(self, request) -> None: # type: ignore[no-untyped-def] + self.request = request + + class MockCtx: """A mock context for stateless REST calls. Tool functions require a ``ctx`` object to report progress. Since REST endpoints are stateless and have no MCP session, this mock provides the required ``info`` and ``report_progress`` methods as no-op async functions. + It also exposes a ``request_context`` with the current Starlette request so + analytics can extract connection fingerprint data. """ + def __init__(self, request=None) -> None: # type: ignore[no-untyped-def] + self.request_context = _RequestContextWrapper(request) if request is not None else None + # Mark source explicitly so analytics can distinguish REST from MCP without path coupling + self.call_source = "rest" + async def info(self, message: str) -> None: """Simulate the ``info`` method of an MCP ``Context``.""" pass @@ -18,6 +32,6 @@ async def report_progress(self, *args, **kwargs) -> None: pass -def get_mock_context() -> MockCtx: +def get_mock_context(request=None) -> MockCtx: # type: ignore[no-untyped-def] """Dependency provider to get a mock context for stateless REST calls.""" - return MockCtx() + return MockCtx(request=request) diff --git a/blockscout_mcp_server/api/routes.py b/blockscout_mcp_server/api/routes.py index 62537fb..52ac730 100644 --- a/blockscout_mcp_server/api/routes.py +++ b/blockscout_mcp_server/api/routes.py @@ -75,20 +75,20 @@ async def main_page(_: Request) -> Response: @handle_rest_errors -async def get_instructions_rest(_: Request) -> Response: +async def get_instructions_rest(request: Request) -> Response: """REST wrapper for the __unlock_blockchain_analysis__ tool.""" # NOTE: This endpoint exists solely for backward compatibility. It duplicates # ``unlock_blockchain_analysis_rest`` instead of delegating to it because the # old route will be removed soon and another wrapper would add needless # indirection. - tool_response = await __unlock_blockchain_analysis__(ctx=get_mock_context()) + tool_response = await __unlock_blockchain_analysis__(ctx=get_mock_context(request)) return JSONResponse(tool_response.model_dump()) @handle_rest_errors -async def unlock_blockchain_analysis_rest(_: Request) -> Response: +async def unlock_blockchain_analysis_rest(request: Request) -> Response: """REST wrapper for the __unlock_blockchain_analysis__ tool.""" - tool_response = await __unlock_blockchain_analysis__(ctx=get_mock_context()) + tool_response = await __unlock_blockchain_analysis__(ctx=get_mock_context(request)) return JSONResponse(tool_response.model_dump()) @@ -100,7 +100,7 @@ async def get_block_info_rest(request: Request) -> Response: required=["chain_id", "number_or_hash"], optional=["include_transactions"], ) - tool_response = await get_block_info(**params, ctx=get_mock_context()) + tool_response = await get_block_info(**params, ctx=get_mock_context(request)) return JSONResponse(tool_response.model_dump()) @@ -108,7 +108,7 @@ async def get_block_info_rest(request: Request) -> Response: async def get_latest_block_rest(request: Request) -> Response: """REST wrapper for the get_latest_block tool.""" params = extract_and_validate_params(request, required=["chain_id"], optional=[]) - tool_response = await get_latest_block(**params, ctx=get_mock_context()) + tool_response = await get_latest_block(**params, ctx=get_mock_context(request)) return JSONResponse(tool_response.model_dump()) @@ -116,7 +116,7 @@ async def get_latest_block_rest(request: Request) -> Response: async def get_address_by_ens_name_rest(request: Request) -> Response: """REST wrapper for the get_address_by_ens_name tool.""" params = extract_and_validate_params(request, required=["name"], optional=[]) - tool_response = await get_address_by_ens_name(**params, ctx=get_mock_context()) + tool_response = await get_address_by_ens_name(**params, ctx=get_mock_context(request)) return JSONResponse(tool_response.model_dump()) @@ -128,7 +128,7 @@ async def get_transactions_by_address_rest(request: Request) -> Response: required=["chain_id", "address"], optional=["age_from", "age_to", "methods", "cursor"], ) - tool_response = await get_transactions_by_address(**params, ctx=get_mock_context()) + tool_response = await get_transactions_by_address(**params, ctx=get_mock_context(request)) return JSONResponse(tool_response.model_dump()) @@ -140,7 +140,7 @@ async def get_token_transfers_by_address_rest(request: Request) -> Response: required=["chain_id", "address"], optional=["age_from", "age_to", "token", "cursor"], ) - tool_response = await get_token_transfers_by_address(**params, ctx=get_mock_context()) + tool_response = await get_token_transfers_by_address(**params, ctx=get_mock_context(request)) return JSONResponse(tool_response.model_dump()) @@ -148,7 +148,7 @@ async def get_token_transfers_by_address_rest(request: Request) -> Response: async def lookup_token_by_symbol_rest(request: Request) -> Response: """REST wrapper for the lookup_token_by_symbol tool.""" params = extract_and_validate_params(request, required=["chain_id", "symbol"], optional=[]) - tool_response = await lookup_token_by_symbol(**params, ctx=get_mock_context()) + tool_response = await lookup_token_by_symbol(**params, ctx=get_mock_context(request)) return JSONResponse(tool_response.model_dump()) @@ -156,7 +156,7 @@ async def lookup_token_by_symbol_rest(request: Request) -> Response: async def get_contract_abi_rest(request: Request) -> Response: """REST wrapper for the get_contract_abi tool.""" params = extract_and_validate_params(request, required=["chain_id", "address"], optional=[]) - tool_response = await get_contract_abi(**params, ctx=get_mock_context()) + tool_response = await get_contract_abi(**params, ctx=get_mock_context(request)) return JSONResponse(tool_response.model_dump()) @@ -181,7 +181,7 @@ async def read_contract_rest(request: Request) -> Response: raise ValueError("Invalid JSON for 'args'") from e if "block" in params and params["block"].isdigit(): params["block"] = int(params["block"]) - tool_response = await read_contract(**params, ctx=get_mock_context()) + tool_response = await read_contract(**params, ctx=get_mock_context(request)) return JSONResponse(tool_response.model_dump()) @@ -189,7 +189,7 @@ async def read_contract_rest(request: Request) -> Response: async def get_address_info_rest(request: Request) -> Response: """REST wrapper for the get_address_info tool.""" params = extract_and_validate_params(request, required=["chain_id", "address"], optional=[]) - tool_response = await get_address_info(**params, ctx=get_mock_context()) + tool_response = await get_address_info(**params, ctx=get_mock_context(request)) return JSONResponse(tool_response.model_dump()) @@ -197,7 +197,7 @@ async def get_address_info_rest(request: Request) -> Response: async def get_tokens_by_address_rest(request: Request) -> Response: """REST wrapper for the get_tokens_by_address tool.""" params = extract_and_validate_params(request, required=["chain_id", "address"], optional=["cursor"]) - tool_response = await get_tokens_by_address(**params, ctx=get_mock_context()) + tool_response = await get_tokens_by_address(**params, ctx=get_mock_context(request)) return JSONResponse(tool_response.model_dump()) @@ -205,7 +205,7 @@ async def get_tokens_by_address_rest(request: Request) -> Response: async def transaction_summary_rest(request: Request) -> Response: """REST wrapper for the transaction_summary tool.""" params = extract_and_validate_params(request, required=["chain_id", "transaction_hash"], optional=[]) - tool_response = await transaction_summary(**params, ctx=get_mock_context()) + tool_response = await transaction_summary(**params, ctx=get_mock_context(request)) return JSONResponse(tool_response.model_dump()) @@ -213,7 +213,7 @@ async def transaction_summary_rest(request: Request) -> Response: async def nft_tokens_by_address_rest(request: Request) -> Response: """REST wrapper for the nft_tokens_by_address tool.""" params = extract_and_validate_params(request, required=["chain_id", "address"], optional=["cursor"]) - tool_response = await nft_tokens_by_address(**params, ctx=get_mock_context()) + tool_response = await nft_tokens_by_address(**params, ctx=get_mock_context(request)) return JSONResponse(tool_response.model_dump()) @@ -225,7 +225,7 @@ async def get_transaction_info_rest(request: Request) -> Response: required=["chain_id", "transaction_hash"], optional=["include_raw_input"], ) - tool_response = await get_transaction_info(**params, ctx=get_mock_context()) + tool_response = await get_transaction_info(**params, ctx=get_mock_context(request)) return JSONResponse(tool_response.model_dump()) @@ -233,20 +233,20 @@ async def get_transaction_info_rest(request: Request) -> Response: async def get_transaction_logs_rest(request: Request) -> Response: """REST wrapper for the get_transaction_logs tool.""" params = extract_and_validate_params(request, required=["chain_id", "transaction_hash"], optional=["cursor"]) - tool_response = await get_transaction_logs(**params, ctx=get_mock_context()) + tool_response = await get_transaction_logs(**params, ctx=get_mock_context(request)) return JSONResponse(tool_response.model_dump()) @handle_rest_errors -async def get_address_logs_rest(_: Request) -> Response: +async def get_address_logs_rest(request: Request) -> Response: """REST wrapper for the get_address_logs tool. This endpoint is deprecated.""" return create_deprecation_response() @handle_rest_errors -async def get_chains_list_rest(_: Request) -> Response: +async def get_chains_list_rest(request: Request) -> Response: """REST wrapper for the get_chains_list tool.""" - tool_response = await get_chains_list(ctx=get_mock_context()) + tool_response = await get_chains_list(ctx=get_mock_context(request)) return JSONResponse(tool_response.model_dump()) diff --git a/blockscout_mcp_server/client_meta.py b/blockscout_mcp_server/client_meta.py new file mode 100644 index 0000000..5be3219 --- /dev/null +++ b/blockscout_mcp_server/client_meta.py @@ -0,0 +1,63 @@ +"""Client metadata extraction and defaults shared across logging and analytics.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +UNDEFINED_CLIENT_NAME = "N/A" +UNDEFINED_CLIENT_VERSION = "N/A" +UNKNOWN_PROTOCOL_VERSION = "Unknown" + + +@dataclass +class ClientMeta: + name: str + version: str + protocol: str + user_agent: str + + +def extract_client_meta_from_ctx(ctx: Any) -> ClientMeta: + """Extract client meta (name, version, protocol, user_agent) from an MCP Context. + + - name: MCP client name. If unavailable, defaults to "N/A" constant or falls back to user agent. + - version: MCP client version. If unavailable, defaults to "N/A" constant. + - protocol: MCP protocol version. If unavailable, defaults to "Unknown" constant. + - user_agent: Extracted from HTTP request headers if available. + """ + client_name = UNDEFINED_CLIENT_NAME + client_version = UNDEFINED_CLIENT_VERSION + protocol: str = UNKNOWN_PROTOCOL_VERSION + user_agent: str = "" + + try: + client_params = getattr(getattr(ctx, "session", None), "client_params", None) + if client_params is not None: + # protocolVersion may be missing + if getattr(client_params, "protocolVersion", None): + protocol = str(client_params.protocolVersion) + client_info = getattr(client_params, "clientInfo", None) + if client_info is not None: + if getattr(client_info, "name", None): + client_name = client_info.name + if getattr(client_info, "version", None): + client_version = client_info.version + # Read User-Agent from HTTP request (if present) + try: + request = getattr(getattr(ctx, "request_context", None), "request", None) + if request is not None: + headers = request.headers or {} + ua = headers.get("user-agent") or headers.get("User-Agent") or "" + user_agent = ua + except Exception: # pragma: no cover + pass + # If client name is still undefined, fallback to User-Agent + if client_name == UNDEFINED_CLIENT_NAME and user_agent: + client_name = user_agent + except Exception: # pragma: no cover - tolerate any ctx shape + pass + + return ClientMeta(name=client_name, version=client_version, protocol=protocol, user_agent=user_agent) + + diff --git a/blockscout_mcp_server/config.py b/blockscout_mcp_server/config.py index d38476d..ed98c69 100644 --- a/blockscout_mcp_server/config.py +++ b/blockscout_mcp_server/config.py @@ -1,9 +1,10 @@ -from pydantic import ConfigDict -from pydantic_settings import BaseSettings +from pydantic_settings import BaseSettings, SettingsConfigDict class ServerConfig(BaseSettings): - model_config = ConfigDict(env_prefix="BLOCKSCOUT_") # e.g., BLOCKSCOUT_BS_URL + # Load environment variables from a local .env file (current working directory) + # and require the BLOCKSCOUT_ prefix for all settings + model_config = SettingsConfigDict(env_prefix="BLOCKSCOUT_", env_file=".env") bs_api_key: str = "" # Default to empty, can be set via env bs_timeout: float = 120.0 # Default timeout in seconds @@ -34,5 +35,9 @@ class ServerConfig(BaseSettings): # Base name used in the User-Agent header sent to Blockscout RPC mcp_user_agent: str = "Blockscout MCP" + # Analytics configuration + mixpanel_token: str = "" + mixpanel_api_host: str = "" # Optional custom API host (e.g., EU region) + config = ServerConfig() diff --git a/blockscout_mcp_server/server.py b/blockscout_mcp_server/server.py index 311fa54..737f26b 100644 --- a/blockscout_mcp_server/server.py +++ b/blockscout_mcp_server/server.py @@ -4,6 +4,7 @@ import uvicorn from mcp.server.fastmcp import FastMCP +from blockscout_mcp_server import analytics from blockscout_mcp_server.constants import ( BLOCK_TIME_ESTIMATION_RULES, CHAIN_ID_RULES, @@ -130,6 +131,8 @@ def main_command( # Configure the existing 'mcp' instance for stateless HTTP with JSON responses mcp.settings.stateless_http = True # Enable stateless mode mcp.settings.json_response = True # Enable JSON responses instead of SSE for tool calls + # Enable analytics in HTTP mode + analytics.set_http_mode(True) asgi_app = mcp.streamable_http_app() asgi_app.add_event_handler("shutdown", WEB3_POOL.close) uvicorn.run(asgi_app, host=http_host, port=http_port) diff --git a/blockscout_mcp_server/tools/decorators.py b/blockscout_mcp_server/tools/decorators.py index 099fcfe..55ccdc9 100644 --- a/blockscout_mcp_server/tools/decorators.py +++ b/blockscout_mcp_server/tools/decorators.py @@ -4,9 +4,8 @@ from collections.abc import Awaitable, Callable from typing import Any -UNDEFINED_CLIENT_NAME = "N/A" -UNDEFINED_CLIENT_VERSION = "N/A" -UNKNOWN_PROTOCOL_VERSION = "Unknown" +from blockscout_mcp_server import analytics +from blockscout_mcp_server.client_meta import extract_client_meta_from_ctx logger = logging.getLogger(__name__) @@ -22,17 +21,22 @@ async def wrapper(*args: Any, **kwargs: Any) -> Any: arg_dict = dict(bound.arguments) ctx = arg_dict.pop("ctx", None) - client_name = UNDEFINED_CLIENT_NAME - client_version = UNDEFINED_CLIENT_VERSION - protocol_version = UNKNOWN_PROTOCOL_VERSION + # Extract client metadata consistently using shared helper + meta = extract_client_meta_from_ctx(ctx) + client_name = meta.name + client_version = meta.version + protocol_version = meta.protocol + # Track analytics (no-op if disabled) try: - if client_params := ctx.session.client_params: - protocol_version = str(client_params.protocolVersion or UNKNOWN_PROTOCOL_VERSION) - if client_info := client_params.clientInfo: - client_name = client_info.name or UNDEFINED_CLIENT_NAME - client_version = client_info.version or UNDEFINED_CLIENT_VERSION - except AttributeError: + analytics.track_tool_invocation( + ctx, + func.__name__, + arg_dict, + client_meta=meta, + ) + except Exception: + # Defensive: tracking must never break tool execution pass log_message = ( diff --git a/pyproject.toml b/pyproject.toml index 860bbb6..3a3ce0e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "blockscout-mcp-server" -version = "0.7.0" +version = "0.8.0-dev" description = "MCP server for Blockscout" requires-python = ">=3.11" dependencies = [ @@ -12,6 +12,7 @@ dependencies = [ "anyio>=4.0.0", # For async task management and progress reporting, "uvicorn>=0.23.1", # For HTTP Streamable mode "web3==7.13.0", + "mixpanel==4.10.1", ] [project.scripts] diff --git a/pytest.ini b/pytest.ini index ff1ed90..8e555de 100644 --- a/pytest.ini +++ b/pytest.ini @@ -1,6 +1,6 @@ # pytest.ini [pytest] -addopts = -m "not integration" +addopts = -m "not integration" --ignore=temp asyncio_default_fixture_loop_scope = function markers = integration: marks tests as integration tests (makes real network calls) diff --git a/tests/test_analytics.py b/tests/test_analytics.py new file mode 100644 index 0000000..4cf1d41 --- /dev/null +++ b/tests/test_analytics.py @@ -0,0 +1,76 @@ +import types +from unittest.mock import MagicMock, patch + +import pytest + +from blockscout_mcp_server import analytics +from blockscout_mcp_server.analytics import ClientMeta +from blockscout_mcp_server.config import config as server_config + + +class DummyRequest: + def __init__(self, headers=None, host="127.0.0.1"): + self.headers = headers or {} + self.client = types.SimpleNamespace(host=host) + + +class DummyCtx: + def __init__(self, request=None, client_name="", client_version=""): + self.request_context = types.SimpleNamespace(request=request) if request else None + clientInfo = types.SimpleNamespace(name=client_name, version=client_version) + self.session = types.SimpleNamespace(client_params=types.SimpleNamespace(clientInfo=clientInfo)) + + +@pytest.fixture(autouse=True) +def reset_mode_and_client(): + analytics.set_http_mode(False) + # Ensure private module state is reset between tests + analytics._mp_client = None # type: ignore[attr-defined] + yield + analytics.set_http_mode(False) + analytics._mp_client = None # type: ignore[attr-defined] + + +def test_noop_when_not_http_mode(monkeypatch): + monkeypatch.setattr(server_config, "mixpanel_token", "test-token", raising=False) + with patch("blockscout_mcp_server.analytics.Mixpanel") as mp_cls: + analytics.track_tool_invocation(DummyCtx(), "some_tool", {"a": 1}) + mp_cls.assert_not_called() + + +def test_noop_when_no_token(monkeypatch): + monkeypatch.setattr(server_config, "mixpanel_token", "", raising=False) + analytics.set_http_mode(True) + with patch("blockscout_mcp_server.analytics.Mixpanel") as mp_cls: + analytics.track_tool_invocation(DummyCtx(), "some_tool", {"a": 1}) + mp_cls.assert_not_called() + + +def test_tracks_with_headers(monkeypatch): + monkeypatch.setattr(server_config, "mixpanel_token", "test-token", raising=False) + headers = {"x-forwarded-for": "203.0.113.5, 70.41.3.18", "user-agent": "pytest-UA"} + req = DummyRequest(headers=headers) + ctx = DummyCtx(request=req, client_name="clientA", 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, + "tool_name", + {"x": 2}, + client_meta=ClientMeta(name="clientA", version="1.0.0", protocol="2024-11-05", user_agent="pytest-UA"), + ) + assert mp_instance.track.called + args, kwargs = mp_instance.track.call_args + # distinct_id, event, properties + assert args[1] == "tool_name" + assert args[2]["ip"] == "203.0.113.5" + assert args[2]["client_name"] == "clientA" + assert args[2]["client_version"] == "1.0.0" + assert args[2]["user_agent"] == "pytest-UA" + assert args[2]["tool_args"] == {"x": 2} + assert args[2]["protocol_version"] == "2024-11-05" + assert kwargs.get("meta") == {"ip": "203.0.113.5"} + + diff --git a/tests/test_analytics_helpers.py b/tests/test_analytics_helpers.py new file mode 100644 index 0000000..c9e36dd --- /dev/null +++ b/tests/test_analytics_helpers.py @@ -0,0 +1,40 @@ +from types import SimpleNamespace + +from blockscout_mcp_server.analytics import _build_distinct_id, _extract_request_ip + + +def test_extract_request_ip_headers_prefer_xff(): + headers = {"x-forwarded-for": "203.0.113.10, 70.41.3.18", "user-agent": "UA-1"} + request = SimpleNamespace(headers=headers, client=SimpleNamespace(host="198.51.100.2")) + ctx = SimpleNamespace(request_context=SimpleNamespace(request=request)) + ip = _extract_request_ip(ctx) + assert ip == "203.0.113.10" + + +def test_extract_request_ip_fallbacks(): + # No xff, but X-Real-IP present + headers = {"X-Real-IP": "192.0.2.9", "User-Agent": "UA-2"} + request = SimpleNamespace(headers=headers, client=SimpleNamespace(host="10.0.0.1")) + ctx = SimpleNamespace(request_context=SimpleNamespace(request=request)) + ip = _extract_request_ip(ctx) + assert ip == "192.0.2.9" + + # Nothing in headers, fallback to client.host + headers2 = {} + request2 = SimpleNamespace(headers=headers2, client=SimpleNamespace(host="10.0.0.5")) + ctx2 = SimpleNamespace(request_context=SimpleNamespace(request=request2)) + ip2 = _extract_request_ip(ctx2) + assert ip2 == "10.0.0.5" + + +def test_build_distinct_id_stable(): + # Same inputs must produce same UUID + a = _build_distinct_id("1.2.3.4", "client", "1.0") + b = _build_distinct_id("1.2.3.4", "client", "1.0") + assert a == b + + # Changing any component changes the result + c = _build_distinct_id("1.2.3.4", "client", "1.1") + assert c != a + + diff --git a/tests/test_analytics_source.py b/tests/test_analytics_source.py new file mode 100644 index 0000000..8121508 --- /dev/null +++ b/tests/test_analytics_source.py @@ -0,0 +1,23 @@ +from types import SimpleNamespace + +from blockscout_mcp_server.analytics import _determine_call_source + + +def test_determine_call_source_explicit_rest(): + ctx = SimpleNamespace(call_source="rest") + assert _determine_call_source(ctx) == "rest" + + +def test_determine_call_source_default_mcp_when_no_marker(): + # No explicit marker; defaults to mcp + ctx = SimpleNamespace() + assert _determine_call_source(ctx) == "mcp" + + +def test_determine_call_source_mcp_when_session_present(): + # Even with no explicit marker, presence of client_params should be treated as mcp + session = SimpleNamespace(client_params=SimpleNamespace()) + ctx = SimpleNamespace(session=session) + assert _determine_call_source(ctx) == "mcp" + + diff --git a/tests/test_client_meta.py b/tests/test_client_meta.py new file mode 100644 index 0000000..004547d --- /dev/null +++ b/tests/test_client_meta.py @@ -0,0 +1,40 @@ +from types import SimpleNamespace + +from blockscout_mcp_server.client_meta import ( + UNDEFINED_CLIENT_NAME, + UNDEFINED_CLIENT_VERSION, + UNKNOWN_PROTOCOL_VERSION, + extract_client_meta_from_ctx, +) + + +def test_extract_client_meta_full(): + client_info = SimpleNamespace(name="clientX", version="2.3.4") + client_params = SimpleNamespace(clientInfo=client_info, protocolVersion="2024-11-05") + ctx = SimpleNamespace(session=SimpleNamespace(client_params=client_params)) + + meta = extract_client_meta_from_ctx(ctx) + assert meta.name == "clientX" + assert meta.version == "2.3.4" + assert meta.protocol == "2024-11-05" + + +def test_extract_client_meta_missing_everything(): + ctx = SimpleNamespace() + meta = extract_client_meta_from_ctx(ctx) + assert meta.name == UNDEFINED_CLIENT_NAME + assert meta.version == UNDEFINED_CLIENT_VERSION + assert meta.protocol == UNKNOWN_PROTOCOL_VERSION + + +def test_extract_client_meta_partial(): + client_info = SimpleNamespace(name=None, version="0.1.0") + client_params = SimpleNamespace(clientInfo=client_info) # no protocolVersion + ctx = SimpleNamespace(session=SimpleNamespace(client_params=client_params)) + + meta = extract_client_meta_from_ctx(ctx) + assert meta.name == UNDEFINED_CLIENT_NAME + assert meta.version == "0.1.0" + assert meta.protocol == UNKNOWN_PROTOCOL_VERSION + + diff --git a/tests/tools/test_decorators.py b/tests/tools/test_decorators.py index e57ac63..4198030 100644 --- a/tests/tools/test_decorators.py +++ b/tests/tools/test_decorators.py @@ -8,6 +8,36 @@ from blockscout_mcp_server.tools.decorators import log_tool_invocation +@pytest.mark.asyncio +async def test_decorator_calls_analytics(monkeypatch, caplog: pytest.LogCaptureFixture) -> None: + # Arrange + caplog.set_level(logging.INFO, logger="blockscout_mcp_server.tools.decorators") + + calls = {} + + def fake_track(ctx, name, args, client_meta=None): # type: ignore[no-untyped-def] + calls["ctx"] = ctx + calls["name"] = name + calls["args"] = args + calls["client_meta"] = client_meta + + monkeypatch.setattr("blockscout_mcp_server.tools.decorators.analytics.track_tool_invocation", fake_track) + + @log_tool_invocation + async def dummy_tool(a: int, ctx: Context) -> int: + return a + + # Act + mock_ctx = MagicMock() + await dummy_tool(7, ctx=mock_ctx) + + # Assert + assert calls["name"] == "dummy_tool" + assert calls["args"] == {"a": 7} + assert calls["ctx"] is mock_ctx + assert "client_meta" in calls + + @pytest.mark.asyncio async def test_log_tool_invocation_decorator(caplog: pytest.LogCaptureFixture, mock_ctx: Context) -> None: caplog.set_level(logging.INFO, logger="blockscout_mcp_server.tools.decorators") From c4d427f8a6f384ab423800fc017515e8e3126a2c Mon Sep 17 00:00:00 2001 From: Alexander Kolotov Date: Wed, 13 Aug 2025 04:35:41 +0000 Subject: [PATCH 02/13] format issues addressed --- blockscout_mcp_server/analytics.py | 2 -- blockscout_mcp_server/client_meta.py | 2 -- tests/test_analytics.py | 2 -- tests/test_analytics_helpers.py | 2 -- tests/test_analytics_source.py | 2 -- tests/test_client_meta.py | 2 -- 6 files changed, 12 deletions(-) diff --git a/blockscout_mcp_server/analytics.py b/blockscout_mcp_server/analytics.py index a9984f1..0dfb673 100644 --- a/blockscout_mcp_server/analytics.py +++ b/blockscout_mcp_server/analytics.py @@ -173,5 +173,3 @@ def track_tool_invocation( mp.track(distinct_id, tool_name, properties) except Exception as exc: # pragma: no cover - do not break tool flow logger.debug("Mixpanel tracking failed for %s: %s", tool_name, exc) - - diff --git a/blockscout_mcp_server/client_meta.py b/blockscout_mcp_server/client_meta.py index 5be3219..0093b61 100644 --- a/blockscout_mcp_server/client_meta.py +++ b/blockscout_mcp_server/client_meta.py @@ -59,5 +59,3 @@ def extract_client_meta_from_ctx(ctx: Any) -> ClientMeta: pass return ClientMeta(name=client_name, version=client_version, protocol=protocol, user_agent=user_agent) - - diff --git a/tests/test_analytics.py b/tests/test_analytics.py index 4cf1d41..fcd69da 100644 --- a/tests/test_analytics.py +++ b/tests/test_analytics.py @@ -72,5 +72,3 @@ def test_tracks_with_headers(monkeypatch): assert args[2]["tool_args"] == {"x": 2} assert args[2]["protocol_version"] == "2024-11-05" assert kwargs.get("meta") == {"ip": "203.0.113.5"} - - diff --git a/tests/test_analytics_helpers.py b/tests/test_analytics_helpers.py index c9e36dd..5936168 100644 --- a/tests/test_analytics_helpers.py +++ b/tests/test_analytics_helpers.py @@ -36,5 +36,3 @@ def test_build_distinct_id_stable(): # Changing any component changes the result c = _build_distinct_id("1.2.3.4", "client", "1.1") assert c != a - - diff --git a/tests/test_analytics_source.py b/tests/test_analytics_source.py index 8121508..46fa3c6 100644 --- a/tests/test_analytics_source.py +++ b/tests/test_analytics_source.py @@ -19,5 +19,3 @@ def test_determine_call_source_mcp_when_session_present(): session = SimpleNamespace(client_params=SimpleNamespace()) ctx = SimpleNamespace(session=session) assert _determine_call_source(ctx) == "mcp" - - diff --git a/tests/test_client_meta.py b/tests/test_client_meta.py index 004547d..fe7f56c 100644 --- a/tests/test_client_meta.py +++ b/tests/test_client_meta.py @@ -36,5 +36,3 @@ def test_extract_client_meta_partial(): assert meta.name == UNDEFINED_CLIENT_NAME assert meta.version == "0.1.0" assert meta.protocol == UNKNOWN_PROTOCOL_VERSION - - From 4d9f6aadb91d0ea7b3b80619d552188793c54398 Mon Sep 17 00:00:00 2001 From: Alexander Kolotov Date: Wed, 13 Aug 2025 18:28:43 +0000 Subject: [PATCH 03/13] code review comments addressed --- blockscout_mcp_server/api/dependencies.py | 15 +++++++++++---- tests/tools/test_decorators.py | 11 ++++------- 2 files changed, 15 insertions(+), 11 deletions(-) diff --git a/blockscout_mcp_server/api/dependencies.py b/blockscout_mcp_server/api/dependencies.py index 18711e0..e6f0fcd 100644 --- a/blockscout_mcp_server/api/dependencies.py +++ b/blockscout_mcp_server/api/dependencies.py @@ -1,11 +1,18 @@ """Dependencies for the REST API, such as mock context providers.""" +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: # pragma: no cover - typing-only import + from starlette.requests import Request + class _RequestContextWrapper: """Lightweight wrapper to mimic MCP's request_context shape for analytics.""" - def __init__(self, request) -> None: # type: ignore[no-untyped-def] - self.request = request + def __init__(self, request: Request) -> None: + self.request: Request = request class MockCtx: @@ -18,7 +25,7 @@ class MockCtx: analytics can extract connection fingerprint data. """ - def __init__(self, request=None) -> None: # type: ignore[no-untyped-def] + def __init__(self, request: Request | None = None) -> None: self.request_context = _RequestContextWrapper(request) if request is not None else None # Mark source explicitly so analytics can distinguish REST from MCP without path coupling self.call_source = "rest" @@ -32,6 +39,6 @@ async def report_progress(self, *args, **kwargs) -> None: pass -def get_mock_context(request=None) -> MockCtx: # type: ignore[no-untyped-def] +def get_mock_context(request: Request | None = None) -> MockCtx: """Dependency provider to get a mock context for stateless REST calls.""" return MockCtx(request=request) diff --git a/tests/tools/test_decorators.py b/tests/tools/test_decorators.py index 4198030..711e9be 100644 --- a/tests/tools/test_decorators.py +++ b/tests/tools/test_decorators.py @@ -9,7 +9,7 @@ @pytest.mark.asyncio -async def test_decorator_calls_analytics(monkeypatch, caplog: pytest.LogCaptureFixture) -> None: +async def test_decorator_calls_analytics(monkeypatch, caplog: pytest.LogCaptureFixture, mock_ctx: Context) -> None: # Arrange caplog.set_level(logging.INFO, logger="blockscout_mcp_server.tools.decorators") @@ -28,7 +28,6 @@ async def dummy_tool(a: int, ctx: Context) -> int: return a # Act - mock_ctx = MagicMock() await dummy_tool(7, ctx=mock_ctx) # Assert @@ -56,7 +55,7 @@ async def dummy_tool(a: int, b: int, ctx: Context) -> int: @pytest.mark.asyncio -async def test_log_tool_invocation_mcp_context(caplog: pytest.LogCaptureFixture) -> None: +async def test_log_tool_invocation_mcp_context(caplog: pytest.LogCaptureFixture, mock_ctx: Context) -> None: """Verify that client info is logged correctly from a full MCP context.""" caplog.set_level(logging.INFO, logger="blockscout_mcp_server.tools.decorators") @@ -70,11 +69,9 @@ async def dummy_tool(a: int, ctx: Context) -> int: capabilities=types.ClientCapabilities(), clientInfo=types.Implementation(name="test-client", version="1.2.3"), ) + mock_ctx.session = mock_session - full_mock_ctx = MagicMock() - full_mock_ctx.session = mock_session - - await dummy_tool(1, ctx=full_mock_ctx) + await dummy_tool(1, ctx=mock_ctx) log_text = caplog.text assert "Tool invoked: dummy_tool" in log_text From 51b51aa45a3e881d4a02f1ebebc3e98bc477d355 Mon Sep 17 00:00:00 2001 From: Alexander Kolotov Date: Wed, 13 Aug 2025 18:33:47 +0000 Subject: [PATCH 04/13] Code review comments addressed --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index cd033c3..b3c5769 100644 --- a/Dockerfile +++ b/Dockerfile @@ -32,7 +32,7 @@ ENV BLOCKSCOUT_ADVANCED_FILTERS_PAGE_SIZE="10" ENV BLOCKSCOUT_RPC_REQUEST_TIMEOUT="60.0" ENV BLOCKSCOUT_RPC_POOL_PER_HOST="50" ENV BLOCKSCOUT_MCP_USER_AGENT="Blockscout MCP" -ENV BLOCKSCOUT_MIXPANEL_TOKEN="" +# ENV BLOCKSCOUT_MIXPANEL_TOKEN="" # Intentionally commented out: pass at runtime to avoid embedding secrets in image ENV BLOCKSCOUT_MIXPANEL_API_HOST="" CMD ["python", "-m", "blockscout_mcp_server"] From b65d47eec35dfdb0c375ae3064ec5c5ffdd3d6b3 Mon Sep 17 00:00:00 2001 From: Alexander Kolotov Date: Wed, 13 Aug 2025 18:53:31 +0000 Subject: [PATCH 05/13] adding explicit env file encoding --- blockscout_mcp_server/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/blockscout_mcp_server/config.py b/blockscout_mcp_server/config.py index ed98c69..b26d34a 100644 --- a/blockscout_mcp_server/config.py +++ b/blockscout_mcp_server/config.py @@ -4,7 +4,7 @@ class ServerConfig(BaseSettings): # Load environment variables from a local .env file (current working directory) # and require the BLOCKSCOUT_ prefix for all settings - model_config = SettingsConfigDict(env_prefix="BLOCKSCOUT_", env_file=".env") + model_config = SettingsConfigDict(env_prefix="BLOCKSCOUT_", env_file=".env", env_file_encoding="utf-8") bs_api_key: str = "" # Default to empty, can be set via env bs_timeout: float = 120.0 # Default timeout in seconds From 648a6d26b2377cd290ae769505d7168a44052f43 Mon Sep 17 00:00:00 2001 From: Alexander Kolotov Date: Wed, 13 Aug 2025 18:54:36 +0000 Subject: [PATCH 06/13] Starlette headers are case-insensitive --- blockscout_mcp_server/analytics.py | 35 +++++++++++++++++++++++----- blockscout_mcp_server/client_meta.py | 12 ++++------ 2 files changed, 33 insertions(+), 14 deletions(-) diff --git a/blockscout_mcp_server/analytics.py b/blockscout_mcp_server/analytics.py index 0dfb673..612c572 100644 --- a/blockscout_mcp_server/analytics.py +++ b/blockscout_mcp_server/analytics.py @@ -31,6 +31,27 @@ _mp_client: Any | None = None +def _get_header_case_insensitive(headers: Any, key: str, default: str | None = None) -> str | None: + """Return header value in a case-insensitive way. + + Supports both Starlette's case-insensitive Headers and plain dicts used in tests. + """ + try: + # Works for Starlette Headers (case-insensitive) and dicts (case-sensitive) + value = headers.get(key, default) + if value not in (None, default): + return value + # Fallback: manual scan for plain dicts or other mappings + items = getattr(headers, "items", None) + if callable(items): + for k, v in items(): + if isinstance(k, str) and k.lower() == key.lower(): + return v + except Exception: # pragma: no cover - defensive + pass + return default + + def set_http_mode(is_http: bool) -> None: """Enable or disable HTTP mode for analytics gating.""" global _is_http_mode_enabled @@ -76,16 +97,18 @@ def _extract_request_ip(ctx: Any) -> str: if request is not None: headers = request.headers or {} # Prefer proxy-forwarded headers - xff = headers.get("x-forwarded-for") or headers.get("X-Forwarded-For") + xff = _get_header_case_insensitive(headers, "x-forwarded-for", "") or "" if xff: # left-most IP per standard ip = xff.split(",")[0].strip() - elif headers.get("x-real-ip") or headers.get("X-Real-IP"): - ip = headers.get("x-real-ip") or headers.get("X-Real-IP") or "" else: - client = getattr(request, "client", None) - if client and getattr(client, "host", None): - ip = client.host + x_real_ip = _get_header_case_insensitive(headers, "x-real-ip", "") or "" + if x_real_ip: + ip = x_real_ip + else: + client = getattr(request, "client", None) + if client and getattr(client, "host", None): + ip = client.host except Exception: # pragma: no cover - tolerate all shapes pass return ip diff --git a/blockscout_mcp_server/client_meta.py b/blockscout_mcp_server/client_meta.py index 0093b61..6a6de83 100644 --- a/blockscout_mcp_server/client_meta.py +++ b/blockscout_mcp_server/client_meta.py @@ -44,14 +44,10 @@ def extract_client_meta_from_ctx(ctx: Any) -> ClientMeta: if getattr(client_info, "version", None): client_version = client_info.version # Read User-Agent from HTTP request (if present) - try: - request = getattr(getattr(ctx, "request_context", None), "request", None) - if request is not None: - headers = request.headers or {} - ua = headers.get("user-agent") or headers.get("User-Agent") or "" - user_agent = ua - except Exception: # pragma: no cover - pass + request = getattr(getattr(ctx, "request_context", None), "request", None) + if request is not None: + headers = request.headers or {} + user_agent = headers.get("user-agent", "") # If client name is still undefined, fallback to User-Agent if client_name == UNDEFINED_CLIENT_NAME and user_agent: client_name = user_agent From 068c98eac56278479ca958d2031ea31e88a9d694 Mon Sep 17 00:00:00 2001 From: Alexander Kolotov Date: Wed, 13 Aug 2025 18:57:06 +0000 Subject: [PATCH 07/13] tests improvements --- tests/test_analytics_helpers.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/tests/test_analytics_helpers.py b/tests/test_analytics_helpers.py index 5936168..212bd61 100644 --- a/tests/test_analytics_helpers.py +++ b/tests/test_analytics_helpers.py @@ -27,6 +27,23 @@ def test_extract_request_ip_fallbacks(): assert ip2 == "10.0.0.5" +def test_extract_request_ip_precedence_when_both_headers_present(): + headers = {"X-Forwarded-For": "198.51.100.10, 203.0.113.20", "X-Real-IP": "192.0.2.9"} + request = SimpleNamespace(headers=headers, client=SimpleNamespace(host="10.0.0.1")) + ctx = SimpleNamespace(request_context=SimpleNamespace(request=request)) + ip = _extract_request_ip(ctx) + # Prefer X-Forwarded-For, left-most IP + assert ip == "198.51.100.10" + + +def test_extract_request_ip_case_insensitive_headers(): + headers = {"X-Forwarded-For": "203.0.113.30"} + request = SimpleNamespace(headers=headers, client=SimpleNamespace(host="10.0.0.1")) + ctx = SimpleNamespace(request_context=SimpleNamespace(request=request)) + ip = _extract_request_ip(ctx) + assert ip == "203.0.113.30" + + def test_build_distinct_id_stable(): # Same inputs must produce same UUID a = _build_distinct_id("1.2.3.4", "client", "1.0") @@ -36,3 +53,7 @@ def test_build_distinct_id_stable(): # Changing any component changes the result c = _build_distinct_id("1.2.3.4", "client", "1.1") assert c != a + d = _build_distinct_id("5.6.7.8", "client", "1.0") + assert d != a + e = _build_distinct_id("1.2.3.4", "clientZ", "1.0") + assert e != a From 710fb8d42126204d32f3da7b561b1b55f05bac6f Mon Sep 17 00:00:00 2001 From: Alexander Kolotov Date: Wed, 13 Aug 2025 19:00:39 +0000 Subject: [PATCH 08/13] tests improved --- tests/test_analytics_source.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/test_analytics_source.py b/tests/test_analytics_source.py index 46fa3c6..53c85cb 100644 --- a/tests/test_analytics_source.py +++ b/tests/test_analytics_source.py @@ -15,7 +15,12 @@ def test_determine_call_source_default_mcp_when_no_marker(): def test_determine_call_source_mcp_when_session_present(): - # Even with no explicit marker, presence of client_params should be treated as mcp + # No explicit marker still defaults to 'mcp' regardless of session presence session = SimpleNamespace(client_params=SimpleNamespace()) ctx = SimpleNamespace(session=session) assert _determine_call_source(ctx) == "mcp" + + +def test_determine_call_source_empty_string_defaults_to_mcp(): + ctx = SimpleNamespace(call_source="") + assert _determine_call_source(ctx) == "mcp" From 29b3e52e1a1e4ceae63802af7149de069260cdf2 Mon Sep 17 00:00:00 2001 From: Alexander Kolotov Date: Wed, 13 Aug 2025 19:16:35 +0000 Subject: [PATCH 09/13] tests improved --- blockscout_mcp_server/analytics.py | 31 +++++++--------------------- blockscout_mcp_server/client_meta.py | 24 ++++++++++++++++++++- tests/test_client_meta.py | 21 +++++++++++++++++++ 3 files changed, 51 insertions(+), 25 deletions(-) diff --git a/blockscout_mcp_server/analytics.py b/blockscout_mcp_server/analytics.py index 612c572..2d82d9a 100644 --- a/blockscout_mcp_server/analytics.py +++ b/blockscout_mcp_server/analytics.py @@ -21,7 +21,11 @@ Consumer = object # type: ignore[assignment] Mixpanel = object # type: ignore[assignment] -from blockscout_mcp_server.client_meta import ClientMeta, extract_client_meta_from_ctx +from blockscout_mcp_server.client_meta import ( + ClientMeta, + extract_client_meta_from_ctx, + get_header_case_insensitive, +) from blockscout_mcp_server.config import config logger = logging.getLogger(__name__) @@ -31,27 +35,6 @@ _mp_client: Any | None = None -def _get_header_case_insensitive(headers: Any, key: str, default: str | None = None) -> str | None: - """Return header value in a case-insensitive way. - - Supports both Starlette's case-insensitive Headers and plain dicts used in tests. - """ - try: - # Works for Starlette Headers (case-insensitive) and dicts (case-sensitive) - value = headers.get(key, default) - if value not in (None, default): - return value - # Fallback: manual scan for plain dicts or other mappings - items = getattr(headers, "items", None) - if callable(items): - for k, v in items(): - if isinstance(k, str) and k.lower() == key.lower(): - return v - except Exception: # pragma: no cover - defensive - pass - return default - - def set_http_mode(is_http: bool) -> None: """Enable or disable HTTP mode for analytics gating.""" global _is_http_mode_enabled @@ -97,12 +80,12 @@ def _extract_request_ip(ctx: Any) -> str: if request is not None: headers = request.headers or {} # Prefer proxy-forwarded headers - xff = _get_header_case_insensitive(headers, "x-forwarded-for", "") or "" + xff = get_header_case_insensitive(headers, "x-forwarded-for", "") or "" if xff: # left-most IP per standard ip = xff.split(",")[0].strip() else: - x_real_ip = _get_header_case_insensitive(headers, "x-real-ip", "") or "" + x_real_ip = get_header_case_insensitive(headers, "x-real-ip", "") or "" if x_real_ip: ip = x_real_ip else: diff --git a/blockscout_mcp_server/client_meta.py b/blockscout_mcp_server/client_meta.py index 6a6de83..a5541d9 100644 --- a/blockscout_mcp_server/client_meta.py +++ b/blockscout_mcp_server/client_meta.py @@ -18,6 +18,28 @@ class ClientMeta: user_agent: str +def get_header_case_insensitive(headers: Any, key: str, default: str = "") -> str: + """Return a header value in a case-insensitive way. + + Works with Starlette's `Headers` (already case-insensitive) and plain dicts. + """ + try: + value = headers.get(key, None) # type: ignore[call-arg] + if value is not None: + return value + except Exception: # pragma: no cover - tolerate any mapping shape + pass + try: + lower_key = key.lower() + items = headers.items() if hasattr(headers, "items") else [] # type: ignore[assignment] + for k, v in items: # type: ignore[assignment] + if isinstance(k, str) and k.lower() == lower_key: + return v + except Exception: # pragma: no cover - tolerate any mapping shape + pass + return default + + def extract_client_meta_from_ctx(ctx: Any) -> ClientMeta: """Extract client meta (name, version, protocol, user_agent) from an MCP Context. @@ -47,7 +69,7 @@ def extract_client_meta_from_ctx(ctx: Any) -> ClientMeta: request = getattr(getattr(ctx, "request_context", None), "request", None) if request is not None: headers = request.headers or {} - user_agent = headers.get("user-agent", "") + user_agent = get_header_case_insensitive(headers, "user-agent", "") # If client name is still undefined, fallback to User-Agent if client_name == UNDEFINED_CLIENT_NAME and user_agent: client_name = user_agent diff --git a/tests/test_client_meta.py b/tests/test_client_meta.py index fe7f56c..87c87d1 100644 --- a/tests/test_client_meta.py +++ b/tests/test_client_meta.py @@ -5,6 +5,7 @@ UNDEFINED_CLIENT_VERSION, UNKNOWN_PROTOCOL_VERSION, extract_client_meta_from_ctx, + get_header_case_insensitive, ) @@ -36,3 +37,23 @@ def test_extract_client_meta_partial(): assert meta.name == UNDEFINED_CLIENT_NAME assert meta.version == "0.1.0" assert meta.protocol == UNKNOWN_PROTOCOL_VERSION + + +def test_extract_client_meta_uses_user_agent_when_name_missing(): + # No clientInfo; user agent present in HTTP request + headers = {"User-Agent": "ua-test/9.9.9"} + request = SimpleNamespace(headers=headers) + ctx = SimpleNamespace(request_context=SimpleNamespace(request=request)) + + meta = extract_client_meta_from_ctx(ctx) + assert meta.name == "ua-test/9.9.9" + assert meta.version == UNDEFINED_CLIENT_VERSION + assert meta.protocol == UNKNOWN_PROTOCOL_VERSION + + +def test_get_header_case_insensitive_with_dict(): + headers = {"User-Agent": "ua-test/1.0", "X-Real-IP": "1.2.3.4"} + assert get_header_case_insensitive(headers, "user-agent") == "ua-test/1.0" + assert get_header_case_insensitive(headers, "USER-AGENT") == "ua-test/1.0" + assert get_header_case_insensitive(headers, "x-real-ip") == "1.2.3.4" + assert get_header_case_insensitive(headers, "missing", "default") == "default" From d3ba35100ee66b2318801670f3e6e81c229fee3f Mon Sep 17 00:00:00 2001 From: Alexander Kolotov Date: Wed, 13 Aug 2025 19:19:56 +0000 Subject: [PATCH 10/13] Avoid direct writes to a private module attribute --- tests/test_analytics.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/test_analytics.py b/tests/test_analytics.py index fcd69da..68eeb98 100644 --- a/tests/test_analytics.py +++ b/tests/test_analytics.py @@ -22,13 +22,13 @@ def __init__(self, request=None, client_name="", client_version=""): @pytest.fixture(autouse=True) -def reset_mode_and_client(): +def reset_mode_and_client(monkeypatch): analytics.set_http_mode(False) # Ensure private module state is reset between tests - analytics._mp_client = None # type: ignore[attr-defined] + monkeypatch.setattr(analytics, "_mp_client", None, raising=False) # type: ignore[attr-defined] yield analytics.set_http_mode(False) - analytics._mp_client = None # type: ignore[attr-defined] + monkeypatch.setattr(analytics, "_mp_client", None, raising=False) # type: ignore[attr-defined] def test_noop_when_not_http_mode(monkeypatch): From 2158e9245eac642785d7f9f60ab0ecee66ed2865 Mon Sep 17 00:00:00 2001 From: Alexander Kolotov Date: Wed, 13 Aug 2025 19:21:51 +0000 Subject: [PATCH 11/13] more specific exception --- blockscout_mcp_server/analytics.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/blockscout_mcp_server/analytics.py b/blockscout_mcp_server/analytics.py index 2d82d9a..6c15917 100644 --- a/blockscout_mcp_server/analytics.py +++ b/blockscout_mcp_server/analytics.py @@ -17,7 +17,7 @@ try: # Import lazily; tests will mock this from mixpanel import Consumer, Mixpanel -except Exception: # pragma: no cover - import errors covered by no-op behavior in tests +except ImportError: # pragma: no cover - import errors covered by no-op behavior in tests Consumer = object # type: ignore[assignment] Mixpanel = object # type: ignore[assignment] From f009df9fec5257544b90dcf35f0bcd8a30bb9ba0 Mon Sep 17 00:00:00 2001 From: Alexander Kolotov Date: Wed, 13 Aug 2025 21:43:20 +0000 Subject: [PATCH 12/13] review comment addressed --- blockscout_mcp_server/analytics.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/blockscout_mcp_server/analytics.py b/blockscout_mcp_server/analytics.py index 6c15917..1337599 100644 --- a/blockscout_mcp_server/analytics.py +++ b/blockscout_mcp_server/analytics.py @@ -17,9 +17,16 @@ try: # Import lazily; tests will mock this from mixpanel import Consumer, Mixpanel -except ImportError: # pragma: no cover - import errors covered by no-op behavior in tests - Consumer = object # type: ignore[assignment] - Mixpanel = object # type: ignore[assignment] +except ImportError: # pragma: no cover + + class _MissingMixpanel: # noqa: D401 - simple placeholder + """Placeholder that raises if Mixpanel is actually used.""" + + def __init__(self, *args: Any, **kwargs: Any) -> None: # noqa: D401 - simple placeholder + raise ImportError("Mixpanel library is not installed. Please install 'mixpanel' to use analytics features.") + + Consumer = _MissingMixpanel # type: ignore[assignment] + Mixpanel = _MissingMixpanel # type: ignore[assignment] from blockscout_mcp_server.client_meta import ( ClientMeta, From 254c25e2b19a8ab898717f5624e15f91f1f24f23 Mon Sep 17 00:00:00 2001 From: Alexander Kolotov Date: Wed, 13 Aug 2025 21:44:06 +0000 Subject: [PATCH 13/13] Updated list of prompt examples --- README.md | 27 +++++++++++++++++---------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index b33f5f5..7434c50 100644 --- a/README.md +++ b/README.md @@ -144,13 +144,7 @@ Refer to [TESTING.md](TESTING.md) for comprehensive instructions on running both ## Example Prompts for AI Agents ```plaintext -On which popular networks is `ens.eth` deployed as a contract? -``` - -```plaintext -What are the usual activities performed by `ens.eth` on the Ethereum Mainnet? -Since it is a contract, what is the most used functionality of this contract? -Which address interacts with the contract the most? +Is any approval set for OP token on Optimism chain by `zeaver.eth`? ``` ```plaintext @@ -163,9 +157,22 @@ before `Nov 08 2024 04:21:35 AM (-06:00 UTC)`? ``` ```plaintext -What is the most recent transaction made to queue a proposal on `0x323A76393544d5ecca80cd6ef2A560C6a395b7E3` -in the Ethereum mainnet? What is the proposal ID? What are the current vote -statistics for this proposal? +Tell me more about the transaction `0xf8a55721f7e2dcf85690aaf81519f7bc820bc58a878fa5f81b12aef5ccda0efb` +on Redstone rollup. +``` + +```plaintext +Is there any blacklisting functionality of USDT token on Arbitrum One? +``` + +```plaintext +What is the latest block on Gnosis Chain and who is the block minter? +Were any funds moved from this minter recently? +``` + +```plaintext +When the most recent reward distribution of Kinto token was made to the wallet +`0x7D467D99028199D99B1c91850C4dea0c82aDDF52` in Kinto chain? ``` ## Development & Deployment