Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
c92fc25
Phase 1: Constants and ToolUsageReport model fields
akolotov Jun 30, 2026
2c01f00
Phase 2: ctx-derived auth-origin and fingerprint helpers
akolotov Jun 30, 2026
6c3dcbc
Phase 3: Direct analytics wiring
akolotov Jun 30, 2026
5f47f1c
Phase 4: Community telemetry payload
akolotov Jun 30, 2026
26d0ade
Phase 5: Observability wiring (decorators and resource reads)
akolotov Jun 30, 2026
28c8b06
Phase 6: REST report-endpoint tests
akolotov Jun 30, 2026
616b782
Phase 7: Documentation Updates
akolotov Jun 30, 2026
06d9519
Phase 8: Version bump
akolotov Jun 30, 2026
1184a14
Refactor: unify auth-origin and fingerprint into compute_auth_signals
akolotov Jun 30, 2026
8ee8b2c
perf(telemetry): derive auth signals once per invocation
akolotov Jun 30, 2026
bc751da
Address review: harden auth-signal derivation, drop redundant validat…
akolotov Jul 1, 2026
d4a17d2
Consolidate auth-signal derivation into a single gated helper
akolotov Jul 1, 2026
09f3419
Address code review on auth-origin/fingerprint usage analytics
akolotov Jul 2, 2026
db9cd27
test: pin disable_community_telemetry in auth-signal tests
akolotov Jul 2, 2026
376517d
Address review: drop harmful auth_origin ctx re-derivation fallback
akolotov Jul 2, 2026
40dc2cd
Address review: memoize server-key fingerprint, drop include_server_f…
akolotov Jul 2, 2026
344d2a6
Tolerate unrecognized auth_origin instead of 422-ing the report
akolotov Jul 2, 2026
f51ee30
Address review: single source of truth for key precedence
akolotov Jul 2, 2026
edc009c
Address review: trim SPEC/AGENTS docs per documentation rules
akolotov Jul 2, 2026
952dc9a
test: dedupe PRO API key ctx builders and fingerprint assertions
akolotov Jul 2, 2026
b81df62
Address review: drop redundant .encode("utf-8") and UP012 noqas
akolotov Jul 2, 2026
2f436cf
Avoid logging swapped-in secrets in auth_origin coercion
akolotov Jul 2, 2026
6c41afd
Simplify auth_origin coercion logging to type/len only
akolotov Jul 2, 2026
a082020
Centralize auth-signal gate in a single telemetry predicate
akolotov Jul 2, 2026
7ca9558
test: address review comments 4-6 (docs, helper reuse, dedup)
akolotov Jul 2, 2026
469ac73
Address code review comment
akolotov Jul 2, 2026
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
6 changes: 5 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ mcp-server/
│ ├── telemetry.py # Fire-and-forget community telemetry reporting
│ ├── client_meta.py # Shared client metadata extraction helpers and defaults
│ ├── observability.py # Resource-read observability helpers
│ ├── pro_api_key_context.py # Request-scoped client-supplied PRO API key state, resolver, and @pro_api_key_scope decorator; per-invocation credit sink and @pro_api_credit_scope decorator
│ ├── pro_api_key_context.py # Request-scoped client-supplied PRO API key state, resolver, and @pro_api_key_scope decorator; per-invocation credit sink and @pro_api_credit_scope decorator; auth-origin and key-fingerprint helpers for analytics/telemetry
│ ├── 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
Expand Down Expand Up @@ -115,6 +115,7 @@ mcp-server/
│ │ ├── test_routes.py # Unit tests for API route definitions
│ │ └── test_skill_resource_routes.py # Unit tests for the bundled skill HTTP mirror
│ ├── 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
│ │ ├── .env.example
│ │ ├── .gemini/
Expand All @@ -135,6 +136,7 @@ mcp-server/
│ ├── test_client_meta.py # Unit tests for client metadata extraction
│ ├── test_observability.py # Unit tests for resource-read observability
│ ├── test_pro_api_key_context.py # Unit tests for client-supplied PRO API key resolution
│ ├── test_pro_api_key_context_auth_signals.py # Unit tests for ctx-derived auth-origin and PRO API key fingerprint signals
│ ├── test_hatch_build.py # Unit tests for custom Hatch build hook helpers
│ ├── test_instructions_data.py # Unit tests for the InstructionsData payload model
│ ├── test_integration_helpers.py # Unit tests for integration test helpers
Expand All @@ -145,6 +147,7 @@ mcp-server/
│ ├── test_bundled_skill_artifacts.py # Unit tests for bundled skill packaging artifacts
│ ├── test_skill_resources_server.py # Unit tests for MCP resource registration
│ ├── test_telemetry.py # Unit tests for telemetry reporting
│ ├── test_tool_usage_report.py # Unit tests for the ToolUsageReport telemetry payload model
│ ├── test_web3_pool.py # Unit tests for web3 pool management
│ ├── resources/ # Unit tests for server-owned resource modules
│ │ ├── __init__.py
Expand Down Expand Up @@ -398,6 +401,7 @@ mcp-server/
* **`pro_api_key_context.py`**:
* Owns request-scoped resolution of a client-supplied Blockscout PRO API key, kept separate from logging/observability.
* Provides a `ContextVar` of the per-request client-key state, a normalization/validation helper, `extract_client_pro_api_key_from_ctx()`, `resolve_pro_api_key()` (precedence: valid client key → server key → not-configured error; malformed client key → terminal error, no fallback), and the `@pro_api_key_scope` decorator.
* Also provides the `ctx`-derived helper `compute_auth_signals()`, used by the analytics and community-telemetry paths.
* Honored for any HTTP request that carries the configured header (MCP-over-HTTP or REST); the key is never logged or placed in cache keys.
* Also defines the per-invocation credit-tracking symbols: `CreditSink`, the `_credit_sink` `ContextVar`, and the `@pro_api_credit_scope` decorator (a sibling of `@pro_api_key_scope`).
* **`cache.py`**:
Expand Down
2 changes: 2 additions & 0 deletions API.md
Original file line number Diff line number Diff line change
Expand Up @@ -671,6 +671,8 @@ Receive an anonymous tool usage report from a community-run server.
| `client_name` | `string` | Yes | Name of the MCP client invoking the tool. |
| `client_version` | `string` | Yes | Version of the MCP client. |
| `protocol_version` | `string` | Yes | Model Context Protocol version used. |
| `auth_origin` | `string \| null` | No | Resolved authorization origin on the reporting instance: `client`, `server`, or `none`. Explicit `null` (or omitted, as from legacy reporters) is treated as `unknown` downstream. |
| `api_key_fingerprint` | `string \| null` | No | One-way, non-reversible SHA-256 fingerprint of the effective PRO API key available to back the request on the reporting instance — the request's authorization context, not whether the invoked tool consumed the key. Non-null values are exactly 64 lowercase hexadecimal characters; never the key itself; explicit `null` (or omitted) when no usable key was available. |

- **Example Request**

Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -454,10 +454,11 @@ To help us improve the Blockscout MCP Server, community-run instances of the ser
- The name of the tool being called (e.g., `get_block_number`).
- The parameters provided to the tool.
- The version of the Blockscout MCP Server being used.
- A one-way, non-reversible hash (SHA-256) of the PRO API key available to authorize the request, when one is present. This is a derived fingerprint only — the key itself is never transmitted and cannot be recovered from the hash.

**What we DO NOT collect:**

- We do not collect any personal data, IP addresses (the central server uses the sender's IP for geolocation via Mixpanel and then discards it), secrets, or private keys.
- We do not collect any personal data, IP addresses (the central server uses the sender's IP for geolocation via Mixpanel and then discards it), or secrets and private keys themselves. The PRO API key in particular is never transmitted — only its one-way, non-reversible fingerprint described above, from which the key cannot be recovered.

### How to Opt-Out

Expand Down
3 changes: 3 additions & 0 deletions SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -897,6 +897,7 @@ To gain insight into tool usage patterns, the server can optionally report tool
- 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.
- Authorization origin: whether the request was backed by a client-supplied PRO API key (`client`), the server-configured key (`server`), or no usable key (`none`). This dimension is orthogonal to the call source — the two compose to describe both how a call arrived and how it was authorized. Community-forwarded events that predate this signal are recorded as `unknown`.

##### Intent Inference from Tool Sequences

Expand All @@ -923,6 +924,7 @@ Intent analytics should favor derived signals over raw text.
- 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.

- 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 @@ -935,6 +937,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.)
- **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.dev1"
__version__ = "0.17.0.dev2"
25 changes: 21 additions & 4 deletions blockscout_mcp_server/analytics.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ def __init__(self, *args: Any, **kwargs: Any) -> None: # noqa: D401 - simple pl
get_header_case_insensitive,
)
from blockscout_mcp_server.config import config
from blockscout_mcp_server.constants import RESOURCE_READ_EVENT
from blockscout_mcp_server.constants import AUTH_ORIGIN_UNKNOWN, RESOURCE_READ_EVENT, AuthOrigin
from blockscout_mcp_server.models import ToolUsageReport

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -191,8 +191,20 @@ def track_tool_invocation(
tool_name: str,
tool_args: dict[str, Any],
client_meta: ClientMeta | None = None,
auth_origin: AuthOrigin | None = None,
) -> None:
"""Track a tool invocation in Mixpanel, if enabled and in HTTP mode."""
"""Track a tool invocation in Mixpanel, if enabled and in HTTP mode.

``auth_origin`` is the pre-computed origin threaded in by the caller — the
observability paths derive the auth signals once per invocation (via
:func:`blockscout_mcp_server.telemetry.resolve_auth_signals`) and reuse them
for both this sink and the community report, so the origin is never
re-derived here. A ``None`` value — which ``resolve_auth_signals`` yields
when derivation is skipped or degrades — is recorded as
``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.
"""
if not _is_http_mode_enabled:
return
mp = _get_mixpanel_client()
Expand Down Expand Up @@ -225,6 +237,7 @@ def track_tool_invocation(
"tool_args": tool_args,
"protocol_version": protocol_version,
"source": _determine_call_source(ctx),
"auth_origin": auth_origin if auth_origin is not None else AUTH_ORIGIN_UNKNOWN,
}

meta = {"ip": ip} if ip else None
Expand All @@ -241,15 +254,18 @@ def track_resource_read(
ctx: Any,
uri: str,
client_meta: ClientMeta | None = None,
auth_origin: AuthOrigin | 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.
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.
"""
track_tool_invocation(ctx, RESOURCE_READ_EVENT, {"uri": uri}, client_meta=client_meta)
track_tool_invocation(ctx, RESOURCE_READ_EVENT, {"uri": uri}, client_meta=client_meta, auth_origin=auth_origin)


def track_community_usage(report: ToolUsageReport, ip: str, user_agent: str) -> None:
Expand All @@ -271,6 +287,7 @@ def track_community_usage(report: ToolUsageReport, ip: str, user_agent: str) ->
"tool_args": report.tool_args,
"protocol_version": report.protocol_version,
"source": "community",
"auth_origin": report.auth_origin if report.auth_origin is not None else AUTH_ORIGIN_UNKNOWN,
}

meta = {"ip": ip} if ip else None
Expand Down
20 changes: 20 additions & 0 deletions blockscout_mcp_server/constants.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
# SPDX-License-Identifier: LicenseRef-Blockscout
"""Constants used throughout the Blockscout MCP Server."""

from typing import Literal

from blockscout_mcp_server import __version__

SERVER_VERSION = __version__
Expand Down Expand Up @@ -104,3 +106,21 @@
# The maximum length for a transaction's input data field before it's truncated.
# 514 = '0x' prefix + 512 hex characters (256 bytes).
INPUT_DATA_TRUNCATION_LIMIT = 514

# Versioned domain-separation prefix for the PRO API key fingerprint hash. The "v1" is
# deliberate: it lets the hashing scheme be versioned later without silently colliding
# with old fingerprints. It is not a secret and provides domain separation, not
# brute-force resistance (PRO API keys are high-entropy, so preimage attacks are
# infeasible; a server-side HMAC pepper is deferred to a follow-up change).
PRO_API_KEY_HASH_PREFIX = "bs-pro-key-v1:"

# The three real authorization origins a key can come from, as computed by the
# ctx-derived helpers. Defined as a single Literal alias (not separate string
# constants) because a Literal[...] annotation cannot be built from string-constant
# variables; the model field and the helper return type both import this alias.
AuthOrigin = Literal["client", "server", "none"]

# Legacy sentinel used only at the Mixpanel layer as the default for community
# reports that predate the `auth_origin` field. Deliberately not part of `AuthOrigin`:
# it is never a valid report value and never returned by the helpers.
AUTH_ORIGIN_UNKNOWN = "unknown"
84 changes: 82 additions & 2 deletions blockscout_mcp_server/models.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,24 @@
# SPDX-License-Identifier: LicenseRef-Blockscout
"""Pydantic models for standardized tool responses."""

from typing import Any, Generic, TypeVar
import logging
import re
from typing import Any, Generic, TypeVar, get_args

from pydantic import BaseModel, ConfigDict, Field
from pydantic import BaseModel, ConfigDict, Field, field_validator

from blockscout_mcp_server.constants import AuthOrigin

logger = logging.getLogger(__name__)

# A fingerprint is exactly 64 lowercase hex characters — the shape of a
# hashlib.sha256(...).hexdigest() digest.
_FINGERPRINT_PATTERN = re.compile(r"[0-9a-f]{64}")

# The wire-valid `auth_origin` values, derived from the `AuthOrigin` Literal so the two
# never drift. A value outside this set (plus JSON null) is coerced to None rather than
# rejected — see ``_tolerate_unknown_auth_origin``.
_VALID_AUTH_ORIGINS = frozenset(get_args(AuthOrigin))

# --- Generic Type Variable ---
T = TypeVar("T")
Expand All @@ -15,6 +30,71 @@ class ToolUsageReport(BaseModel):
client_name: str
client_version: str
protocol_version: str
auth_origin: AuthOrigin | None = Field(
default=None,
description=(
"The origin of the authorization available to back the reported call: 'client' for a "
"client-supplied PRO API key, 'server' for a server-configured key, or 'none' for "
"no usable key. Reflects the request's authorization context, not whether the invoked "
"tool actually consumed a key. Absent on legacy payloads that predate this field. An "
"unrecognized value (unknown enum member, wrong case, or non-string) is coerced to null "
"and reported as 'unknown' downstream, so a version-skewed reporter never drops an "
"otherwise-valid report."
),
)
api_key_fingerprint: str | None = Field(
default=None,
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."
),
)

@field_validator("auth_origin", mode="before")
@classmethod
def _tolerate_unknown_auth_origin(cls, value: Any) -> str | None:
"""Coerce an unrecognized ``auth_origin`` to ``None`` (→ ``unknown`` downstream) instead of rejecting.

``auth_origin`` is consumed as a Mixpanel dimension, but the reports arrive fire-and-forget
from independently-versioned community instances, so a value this receiver does not (yet)
recognize — a future enum member rolled out to reporters first, wrong capitalization, or
non-string junk — must not 422 the whole otherwise-valid report. Anything outside
``_VALID_AUTH_ORIGINS`` (plus JSON null) is coerced to ``None``, which
:func:`analytics.track_community_usage` already renders as the ``unknown`` bucket that legacy
(field-absent) reports land in. This keeps both forward-compat wire signals symmetric with
``api_key_fingerprint`` below. The debug log preserves visibility into version skew that a
silent 422 (dropped on both ends) would hide; it records only the value's type and length —
never the value itself — mirroring :meth:`_tolerate_malformed_fingerprint`.
"""
if value is None or (isinstance(value, str) and value in _VALID_AUTH_ORIGINS):
return value
logger.debug(
"Coercing unrecognized auth_origin to None (type=%s, len=%s)",
type(value).__name__,
len(value) if isinstance(value, str) else "n/a",
)
return 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.
"""
if value is None or (isinstance(value, str) and _FINGERPRINT_PATTERN.fullmatch(value)):
return value
logger.debug("Coercing malformed api_key_fingerprint to None (type=%s)", type(value).__name__)
return None


# --- Models for Pagination ---
Expand Down
Loading
Loading