Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@ mcp-server/
│ │ ├── test_resource_routes.py # Unit tests for resource discovery routes (/v1/resources)
│ │ ├── test_routes.py # Unit tests for API route definitions
│ │ └── test_skill_resource_routes.py # Unit tests for the bundled skill HTTP mirror
│ ├── analytics_ctx_helpers.py # Shared request/context test doubles for the analytics test modules
│ ├── conftest.py
│ ├── pro_api_key_helpers.py # Shared request-context builders for PRO API key / auth-signal tests
│ ├── evals/ # Evaluation artifacts and runner configs for tool output checks
Expand All @@ -131,6 +132,7 @@ mcp-server/
│ │ └── run.sh # Script to execute eval runs
│ ├── test_analytics.py # Unit tests for analytics event tracking
│ ├── test_analytics_helpers.py # Unit tests for analytics helper utilities
│ ├── test_analytics_identity.py # Unit tests for distinct_id identity-basis selection
│ ├── test_analytics_source.py # Unit tests for analytics source detection
│ ├── test_cache.py # Unit tests for cache behavior
│ ├── test_client_meta.py # Unit tests for client metadata extraction
Expand Down Expand Up @@ -362,7 +364,7 @@ mcp-server/
* **`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.
* Generates a deterministic anonymous `distinct_id` — key-fingerprint-based where a key distinguishes the caller, otherwise an IP/name/version composite.
* 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.
* **`telemetry.py`**:
Expand Down
11 changes: 6 additions & 5 deletions SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -926,10 +926,11 @@ Intent analytics should favor derived signals over raw text.
- When exporting telemetry externally, apply truncation/redaction to sensitive argument values where practical.

- 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://mcp.blockscout.com/mcp`), client IP, client name, and client version.
- This yields stable identification even when multiple clients share the same name/version (e.g., Claude Desktop) because their IPs differ.
- A future iteration will fold the effective-key fingerprint into `distinct_id` on this direct path: when the caller supplies a client key, the client-key fingerprint identifies the user; absent a client key, the composite stays IP + client name + version — the public server's server key is shared by all callers, so it is deliberately **not** used as an identity. This deferred follow-up (see *Community Telemetry* below) is why the effective-key fingerprint is derived whenever any telemetry sink is active, not only when community telemetry is enabled, even though only community reports consume it today.
- A stable `distinct_id` anonymously identifies unique users. Two derivation bases share the same UUIDv5 scheme and namespace URL (`https://mcp.blockscout.com/mcp`), so no emitted identity is distinguishable as key-derived and the key-fingerprint digest never appears in an event.
- Key-fingerprint basis: when the effective-key fingerprint distinguishes a distinct user or installation, `distinct_id` is derived from it (the input — the `key:` domain tag plus the 64-character hex digest — contains no `|`, while every heuristic composite contains exactly two, so the two bases' input spaces can never collide). This identity survives client upgrades, IP churn, and transport changes.
- Heuristic basis (fallback): the composite of client IP, client name, and client version. It still separates clients sharing a name/version (e.g., Claude Desktop) by their IPs, and it is what keyless traffic and reports without a usable fingerprint fall back to.
- The basis is keyed on how the event reached analytics, not on `auth_origin` alone: on the direct path (tool invocations and resource reads — the landing PageView event is deliberately out of scope and keeps its IP/User-Agent identity) only a caller-supplied key (`auth_origin` = `client`) selects the fingerprint basis, because the public server's own configured key is shared by every anonymous caller and would collapse them into one identity; on the community path any present fingerprint selects it — the server-key fingerprint identifies a self-hosted installation, a client-key fingerprint that installation's individual users. On the community path the `ip` property (and hence geolocation) belongs to the reporting instance, not the end user.
- Switching keyed traffic to the fingerprint basis intentionally starts a new identity epoch: no Mixpanel identity-merge mechanism links the new IDs to prior heuristic IDs, and a rollback simply reverts to the heuristic basis. The one-time uniques discontinuity is accepted — the heuristic basis never provided real continuity anyway (IP churn and client upgrades already reset it).

- 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`).
Expand All @@ -942,7 +943,7 @@ Intent analytics should favor derived signals over raw text.
- **HTTP mode**: Active only when both `BLOCKSCOUT_MIXPANEL_TOKEN` is not configured AND `BLOCKSCOUT_DISABLE_COMMUNITY_TELEMETRY` is not set to true
- **Mechanism**: To understand usage in the open-source community, these instances send an anonymous, "fire-and-forget" report to a central endpoint (`POST /v1/report_tool_usage`) on the official Blockscout MCP server. This report contains the tool name, tool arguments, the MCP client name and version, the model context protocol version, and the server's version.
- **Central Processing**: The central server receives this report, uses the sender's IP address for geolocation, and forwards the event to Mixpanel with the client metadata, protocol version, and a `source` property of `"community"`. This allows us to gather valuable aggregate statistics without requiring every user to have a Mixpanel account.
- **Authorization context**: Community reports also carry the request's authorization origin and a one-way, non-reversible fingerprint of the effective PRO API key, so direct and community analytics share the same authorization-context dimension. The raw key never leaves the instance — only the fingerprint, and only when a usable key was available. Because these reports arrive fire-and-forget from independently-versioned community instances, both new fields tolerate unrecognized wire values rather than dropping an otherwise-valid report. Consuming and retaining the fingerprint for stronger unique-user identity — the client-key fingerprint per user, or on a personal self-hosted instance the server-key fingerprint per instance — together with a server-side HMAC pepper, is deferred to a dedicated follow-up. (Exact field names, enum values, and hex-shape constraints are documented in `API.md`; the coercion behavior lives in the `ToolUsageReport` validators.)
- **Authorization context**: Community reports also carry the request's authorization origin and a one-way, non-reversible fingerprint of the effective PRO API key, so direct and community analytics share the same authorization-context dimension. The raw key never leaves the instance — only the fingerprint, and only when a usable key was available. Because these reports arrive fire-and-forget from independently-versioned community instances, both fields tolerate unrecognized wire values rather than dropping an otherwise-valid report; a coerced-away fingerprint simply degrades that event to the heuristic identity basis. The receiving server consumes any present fingerprint as the `distinct_id` basis (see *Anonymous identity* above). Since the endpoint is unauthenticated by design, a forged report can thereby direct events into any chosen fingerprint-derived identity — an analytics-integrity (not confidentiality) risk accepted for fire-and-forget telemetry, whose free-text fields already permitted unbounded fabricated identities. A server-side HMAC pepper for this derivation was considered and rejected despite a real compartmentalization benefit (it would block correlation by a party holding a known fingerprint plus a Mixpanel export but not the server's secrets — a residual risk we accept): it does not protect against the operator, who holds the keys, the pepper, and the Mixpanel project alike; it adds secret provisioning/rotation/recovery machinery contrary to this feature's no-new-configuration goal; and pepper loss or rotation would silently reset every key-derived identity — a new failure mode for exactly the stability this identity exists to provide. (Exact field names, enum values, and hex-shape constraints are documented in `API.md`; the coercion behavior lives in the `ToolUsageReport` validators.)
- **Opt-Out**: This community reporting can be completely disabled by setting the `BLOCKSCOUT_DISABLE_COMMUNITY_TELEMETRY` environment variable to `true`.

##### Resource-Read Observability
Expand Down
2 changes: 1 addition & 1 deletion blockscout_mcp_server/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# SPDX-License-Identifier: LicenseRef-Blockscout
"""Blockscout MCP Server package."""

__version__ = "0.17.0"
__version__ = "0.18.0.dev0"
78 changes: 69 additions & 9 deletions blockscout_mcp_server/analytics.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,11 @@
- 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.
Events are emitted via Mixpanel with a deterministic distinct_id derived from one
of two bases, selected per-path: the legacy composite of client IP, client name,
and client version, or the caller's PRO API key fingerprint. Which basis applies
depends on how the event reached analytics -- see `track_tool_invocation` and
`track_community_usage` for the exact selection rule on each path.
"""

from __future__ import annotations
Expand Down Expand Up @@ -126,6 +129,16 @@ def _build_distinct_id(ip: str, client_name: str, client_version: str) -> str:
return str(uuid.uuid5(uuid.NAMESPACE_URL, "https://mcp.blockscout.com/mcp" + composite))


def _build_fingerprint_distinct_id(fingerprint: str) -> str:
# The two bases' input spaces are structurally disjoint: every input here is "key:" plus
# 64 hex characters (a sha256 hexdigest on the direct path, validator-enforced shape on
# the community path) and so contains no "|", while every legacy composite above contains
# exactly two "|" separators. The "key:" tag is not what prevents the collision — a
# composite CAN start with "key:", since its ip field is free text from a spoofable
# header — it future-proofs the separation should the composite format ever change.
return str(uuid.uuid5(uuid.NAMESPACE_URL, "https://mcp.blockscout.com/mcp" + "key:" + fingerprint))


def _determine_call_source(ctx: Any) -> str:
"""Return 'mcp' for MCP calls, 'rest' for REST API, else 'unknown'.

Expand Down Expand Up @@ -192,6 +205,7 @@ def track_tool_invocation(
tool_args: dict[str, Any],
client_meta: ClientMeta | None = None,
auth_origin: AuthOrigin | None = None,
api_key_fingerprint: str | None = None,
) -> None:
"""Track a tool invocation in Mixpanel, if enabled and in HTTP mode.

Expand All @@ -204,6 +218,23 @@ def track_tool_invocation(
``AUTH_ORIGIN_UNKNOWN``, mirroring :func:`track_community_usage`. Re-deriving
from ``ctx`` at this point would re-run the very computation that just failed
and lose the whole event, so it is deliberately avoided.

``api_key_fingerprint`` is likewise threaded, pre-computed, and never
re-derived here — same contract as ``auth_origin``. On this direct path it
selects the fingerprint identity basis if and only if ``auth_origin ==
"client"`` **and** a fingerprint was actually provided; every other
combination (``"server"``, ``"none"``, an unknown/``None`` origin, or a
``client`` origin with a missing fingerprint) keeps the legacy IP/name/version
composite. This is deliberately narrower than the community path
(:func:`track_community_usage`), which uses any present fingerprint
regardless of origin: on the direct path the server's own configured key is
shared by every anonymous caller, so using its fingerprint here would
collapse them all into a single Mixpanel identity. Only a caller-supplied key
genuinely distinguishes anyone on this path. A ``client`` origin paired with
``None`` fingerprint cannot happen when signals come from
:func:`blockscout_mcp_server.telemetry.resolve_auth_signals` (its branches
pair origin and fingerprint atomically), but this sink does not rely on that
caller contract and degrades to the legacy basis instead of raising.
"""
if not _is_http_mode_enabled:
return
Expand All @@ -227,7 +258,13 @@ def track_tool_invocation(
protocol_version = meta.protocol
user_agent = meta.user_agent

distinct_id = _build_distinct_id(ip, client_name, client_version)
# Fingerprint basis only when the caller-supplied key is what produced it (auth_origin
# == "client"); the shared server key must never collapse all anonymous callers into one
# identity (see the docstring above for the full rationale).
if auth_origin == "client" and api_key_fingerprint:
distinct_id = _build_fingerprint_distinct_id(api_key_fingerprint)
else:
distinct_id = _build_distinct_id(ip, client_name, client_version)

properties: dict[str, Any] = {
"ip": ip,
Expand Down Expand Up @@ -255,29 +292,52 @@ def track_resource_read(
uri: str,
client_meta: ClientMeta | None = None,
auth_origin: AuthOrigin | None = None,
api_key_fingerprint: str | None = None,
) -> None:
"""Track a resource read in Mixpanel, if enabled and in HTTP mode.

Delegates to :func:`track_tool_invocation` using the ``RESOURCE_READ`` event
sentinel so that all gating logic (HTTP-mode, token, IP extraction, etc.) is
reused verbatim. The caller is responsible for providing a fully-normalised
URI string — this function does not stringify. ``auth_origin`` is threaded
through to :func:`track_tool_invocation` (see its docstring) so the resource
observability path also derives the auth signals only once per read.
URI string — this function does not stringify. ``auth_origin`` and
``api_key_fingerprint`` are threaded through to :func:`track_tool_invocation`
unchanged (see its docstring for the identity-basis selection rule) so the
resource observability path also derives the auth signals only once per read.
"""
track_tool_invocation(ctx, RESOURCE_READ_EVENT, {"uri": uri}, client_meta=client_meta, auth_origin=auth_origin)
track_tool_invocation(
ctx,
RESOURCE_READ_EVENT,
{"uri": uri},
client_meta=client_meta,
auth_origin=auth_origin,
api_key_fingerprint=api_key_fingerprint,
)


def track_community_usage(report: ToolUsageReport, ip: str, user_agent: str) -> None:
"""Track a tool invocation from a community (self-hosted) server."""
"""Track a tool invocation from a community (self-hosted) server.

``distinct_id`` is keyed on the report's ``api_key_fingerprint`` whenever one is
present, regardless of ``auth_origin`` — unlike the direct path (see
:func:`track_tool_invocation`), a community report's server-key fingerprint
identifies a self-hosted installation and a client-key fingerprint identifies
that installation's individual users, so either is a meaningful identity here.
The ``ToolUsageReport`` validator (``_tolerate_malformed_fingerprint``) already
guarantees present ⇒ valid 64-hex, so no shape-checking is needed at this call
site. Reports without a fingerprint keep using the legacy ip/client composite so
legacy reporters are still counted.
"""
if not _is_http_mode_enabled:
return
mp = _get_mixpanel_client()
if mp is None:
return

try:
distinct_id = _build_distinct_id(ip, report.client_name, report.client_version)
if report.api_key_fingerprint:
distinct_id = _build_fingerprint_distinct_id(report.api_key_fingerprint)
else:
distinct_id = _build_distinct_id(ip, report.client_name, report.client_version)

properties: dict[str, Any] = {
"ip": ip,
Expand Down
25 changes: 13 additions & 12 deletions blockscout_mcp_server/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,10 +47,10 @@ class ToolUsageReport(BaseModel):
description=(
"A one-way, non-reversible SHA-256 hex digest fingerprint of the effective PRO API "
"key available to back the reported call, or null if no usable key was available. "
"A valid value is exactly 64 lowercase hex characters. Because this field is accepted "
"over the wire but not yet consumed (not forwarded to Mixpanel, not persisted), a "
"malformed value is tolerated: it is coerced to null rather than rejecting the "
"otherwise-valid report."
"A valid value is exactly 64 lowercase hex characters. The digest is never forwarded "
"verbatim to Mixpanel. A malformed value is tolerated: it is coerced to null rather "
"than rejecting the otherwise-valid report, and that event simply degrades to the "
"heuristic distinct_id basis."
),
)

Expand Down Expand Up @@ -82,14 +82,15 @@ def _tolerate_unknown_auth_origin(cls, value: Any) -> str | None:
@field_validator("api_key_fingerprint", mode="before")
@classmethod
def _tolerate_malformed_fingerprint(cls, value: Any) -> str | None:
"""Coerce a malformed, not-yet-consumed fingerprint to ``None`` instead of rejecting.

The fingerprint is a forward-compatible wire signal that no consumer reads yet, so one
malformed value must not drop an otherwise-valid community report. Any value that is not
``None`` and not a ``str`` matching the 64-lowercase-hex ``_FINGERPRINT_PATTERN`` (including
non-string junk) is coerced to ``None``, preserving the "present ⇒ valid 64-hex" invariant
for the deferred identity follow-up. ``auth_origin`` is coerced the same way (to ``None`` →
``unknown``) by :meth:`_tolerate_unknown_auth_origin` above.
"""Coerce a malformed fingerprint to ``None`` instead of rejecting.

The fingerprint feeds Mixpanel ``distinct_id`` derivation, so one malformed value must not
drop an otherwise-valid community report. Any value that is not ``None`` and not a ``str``
matching the 64-lowercase-hex ``_FINGERPRINT_PATTERN`` (including non-string junk) is
coerced to ``None``, preserving the "present ⇒ valid 64-hex" invariant that identity
derivation relies on — a malformed value degrades that event to the heuristic
``distinct_id`` basis rather than being dropped. ``auth_origin`` is coerced the same way
(to ``None`` → ``unknown``) by :meth:`_tolerate_unknown_auth_origin` above.
"""
if value is None or (isinstance(value, str) and _FINGERPRINT_PATTERN.fullmatch(value)):
return value
Expand Down
4 changes: 3 additions & 1 deletion blockscout_mcp_server/observability.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,9 @@ def log_resource_read(uri: Any, ctx: Any) -> None:

# Step 2 — direct analytics sink (self-gating, synchronous).
try:
analytics.track_resource_read(ctx, full_uri, client_meta=meta, auth_origin=auth_origin)
analytics.track_resource_read(
ctx, full_uri, client_meta=meta, auth_origin=auth_origin, api_key_fingerprint=api_key_fingerprint
)
except Exception:
pass

Expand Down
Loading
Loading