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..b3c5769 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="" # Intentionally commented out: pass at runtime to avoid embedding secrets in image +ENV BLOCKSCOUT_MIXPANEL_API_HOST="" CMD ["python", "-m", "blockscout_mcp_server"] 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 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..1337599 --- /dev/null +++ b/blockscout_mcp_server/analytics.py @@ -0,0 +1,188 @@ +"""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 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, + extract_client_meta_from_ctx, + get_header_case_insensitive, +) +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 = 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 "" + 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 + + +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..e6f0fcd 100644 --- a/blockscout_mcp_server/api/dependencies.py +++ b/blockscout_mcp_server/api/dependencies.py @@ -1,5 +1,19 @@ """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: Request) -> None: + self.request: Request = request + class MockCtx: """A mock context for stateless REST calls. @@ -7,8 +21,15 @@ class MockCtx: 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: 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" + async def info(self, message: str) -> None: """Simulate the ``info`` method of an MCP ``Context``.""" pass @@ -18,6 +39,6 @@ async def report_progress(self, *args, **kwargs) -> None: pass -def get_mock_context() -> MockCtx: +def get_mock_context(request: Request | None = None) -> MockCtx: """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..a5541d9 --- /dev/null +++ b/blockscout_mcp_server/client_meta.py @@ -0,0 +1,79 @@ +"""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 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. + + - 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) + request = getattr(getattr(ctx, "request_context", None), "request", None) + if request is not None: + headers = request.headers or {} + 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 + 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..b26d34a 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", 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 @@ -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..68eeb98 --- /dev/null +++ b/tests/test_analytics.py @@ -0,0 +1,74 @@ +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(monkeypatch): + analytics.set_http_mode(False) + # Ensure private module state is reset between tests + monkeypatch.setattr(analytics, "_mp_client", None, raising=False) # type: ignore[attr-defined] + yield + analytics.set_http_mode(False) + monkeypatch.setattr(analytics, "_mp_client", None, raising=False) # 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..212bd61 --- /dev/null +++ b/tests/test_analytics_helpers.py @@ -0,0 +1,59 @@ +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_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") + 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 + 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 diff --git a/tests/test_analytics_source.py b/tests/test_analytics_source.py new file mode 100644 index 0000000..53c85cb --- /dev/null +++ b/tests/test_analytics_source.py @@ -0,0 +1,26 @@ +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(): + # 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" diff --git a/tests/test_client_meta.py b/tests/test_client_meta.py new file mode 100644 index 0000000..87c87d1 --- /dev/null +++ b/tests/test_client_meta.py @@ -0,0 +1,59 @@ +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, + get_header_case_insensitive, +) + + +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 + + +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" diff --git a/tests/tools/test_decorators.py b/tests/tools/test_decorators.py index e57ac63..711e9be 100644 --- a/tests/tools/test_decorators.py +++ b/tests/tools/test_decorators.py @@ -8,6 +8,35 @@ from blockscout_mcp_server.tools.decorators import log_tool_invocation +@pytest.mark.asyncio +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") + + 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 + 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") @@ -26,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") @@ -40,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