From 2059d2716715a50059a5da7e5e5df5dc7907f9ea Mon Sep 17 00:00:00 2001 From: Georgis Andonis Date: Thu, 30 Jul 2026 16:24:49 +0300 Subject: [PATCH 1/7] feat(mcp): read client identity from request _meta MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The MCP 2026-07-28 stateless revision removes the initialize handshake and the Mcp-Session-Id header (SEP-2575 / SEP-2567). Client name/version and the protocol version now travel in every request's params._meta under io.modelcontextprotocol/clientInfo and /protocolVersion. Python previously derived client identity only from client_params.clientInfo (i.e. from initialize) or from the session token it mints during initialize. Under the new revision both sources disappear, so $mcp_client_name, $mcp_client_version and $mcp_protocol_version would all go silently null. Add a _client_identity module that reads those keys off whatever the call site has on hand (a FastMCP Context, a low-level RequestContext, a request or params object, or a plain dict) and layer it into resolve_session_and_client, which all four adapter call sites already share. _meta takes precedence over both the transport values and the session token, since it is the per-request truth under the new revision; when it is absent nothing changes, so legacy clients behave exactly as before. Identity is resolved per request rather than in server-wide state, so a server multiplexing concurrent requests from different clients cannot cross-attribute them — a real hazard under the stateless spec. Closes a parity gap with the TypeScript SDK, which shipped this in @posthog/mcp 0.10.1 (PostHog/posthog-js#4237). Generated-By: PostHog Code Task-Id: 14a95eda-2573-4c9a-9191-d5eecb8e8d44 --- .sampo/changesets/mcp-meta-client-identity.md | 5 + posthog/mcp/_client_identity.py | 136 +++++++++ posthog/mcp/_instrument_fastmcp.py | 12 +- posthog/mcp/_instrument_lowlevel.py | 12 +- posthog/mcp/_instrumentation.py | 15 +- posthog/test/mcp/test_client_identity.py | 282 ++++++++++++++++++ 6 files changed, 457 insertions(+), 5 deletions(-) create mode 100644 .sampo/changesets/mcp-meta-client-identity.md create mode 100644 posthog/mcp/_client_identity.py create mode 100644 posthog/test/mcp/test_client_identity.py diff --git a/.sampo/changesets/mcp-meta-client-identity.md b/.sampo/changesets/mcp-meta-client-identity.md new file mode 100644 index 00000000..1df374f3 --- /dev/null +++ b/.sampo/changesets/mcp-meta-client-identity.md @@ -0,0 +1,5 @@ +--- +pypi/posthog: minor +--- + +feat(mcp): read the MCP client name/version and protocol version from each request's `_meta` (`io.modelcontextprotocol/clientInfo` and `io.modelcontextprotocol/protocolVersion`), so `$mcp_client_name`, `$mcp_client_version`, and `$mcp_protocol_version` keep populating under the MCP 2026-07-28 stateless revision, which removes the `initialize` handshake (parity with the TypeScript SDK). Existing clients are unaffected — when `_meta` is absent, the values from the session token / `initialize` still apply. diff --git a/posthog/mcp/_client_identity.py b/posthog/mcp/_client_identity.py new file mode 100644 index 00000000..7407cb40 --- /dev/null +++ b/posthog/mcp/_client_identity.py @@ -0,0 +1,136 @@ +"""Client identity under the MCP 2026-07-28 (stateless) revision. + +That revision removes the ``initialize`` handshake and the ``Mcp-Session-Id`` +header (SEP-2575 / SEP-2567). Client name/version and the protocol version no +longer arrive once at ``initialize`` — they travel in every request's +``params._meta`` under the reverse-DNS keys below. We mirror the literal key +strings here rather than depend on the (still beta) v2 SDK, which is not a +dependency of this package. + +Reading them per request also keeps identity correct when one instrumented +server multiplexes concurrent requests from different clients, which the +stateless spec allows: each request resolves its own identity instead of +sharing server-wide state. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Mapping, Optional, Tuple + +META_CLIENT_INFO_KEY = "io.modelcontextprotocol/clientInfo" +META_PROTOCOL_VERSION_KEY = "io.modelcontextprotocol/protocolVersion" + +# Depth cap for the source walk below; the real nesting is at most +# context -> request_context -> params -> meta. +_MAX_UNWRAP_DEPTH = 6 + + +@dataclass +class MetaClientInfo: + """Whatever slice of the client identity a request's ``_meta`` carried.""" + + client_name: Optional[str] = None + client_version: Optional[str] = None + protocol_version: Optional[str] = None + + +def _non_empty_str(value: Any) -> Optional[str]: + """Keep only non-empty strings — a client sending ``""`` or a non-string + shouldn't blank out an otherwise-good value from the token or ``initialize``.""" + return value if isinstance(value, str) and value else None + + +def _extract_meta(source: Any, depth: int = 0) -> Optional[Mapping[str, Any]]: + """Best-effort walk to the ``_meta`` mapping. + + Accepts any of the shapes the call sites have on hand: a FastMCP ``Context``, + a low-level ``RequestContext``, an MCP request object, a request params + object, the ``RequestParams.Meta`` model itself, or a plain dict of any of + those. Returns ``None`` when the source carries no ``_meta``.""" + if source is None or depth > _MAX_UNWRAP_DEPTH: + return None + + if isinstance(source, Mapping): + # A JSON-RPC-ish request dict: descend into its params. + if "params" in source or "method" in source: + return _extract_meta(source.get("params"), depth + 1) + # Params: the wire spells this `_meta`, but `request_to_dict` dumps + # pydantic params without `by_alias`, so it comes through as `meta`. + for key in ("_meta", "meta"): + nested = source.get(key) + if isinstance(nested, Mapping): + return nested + # Otherwise assume we were handed the meta mapping directly. + return source + + # Descend toward the meta object first. Request and params models are + # `extra="allow"` as well, so checking `model_extra` before descending would + # match the wrong (and normally empty) level and stop the walk early. + for attr in ("meta", "params", "request_context"): + try: + nested = getattr(source, attr, None) + except Exception: # noqa: BLE001 - property access can raise off-request + nested = None + if nested is not None and nested is not source: + found = _extract_meta(nested, depth + 1) + if found is not None: + return found + + # `RequestParams.Meta` is `extra="allow"`, so the reverse-DNS keys — which + # aren't declared fields — land in `model_extra`. + extra = getattr(source, "model_extra", None) + if isinstance(extra, Mapping) and extra: + return extra + return None + + +def read_meta_client_info(source: Any) -> Optional[MetaClientInfo]: + """Read the client name/version and protocol version a modern client puts in + ``params._meta``. Returns ``None`` when the request carries none (e.g. a + legacy client, which sends this at ``initialize`` instead). Never raises.""" + try: + meta = _extract_meta(source) + except Exception: # noqa: BLE001 - identity is best-effort, never fatal + return None + if not isinstance(meta, Mapping): + return None + + info = MetaClientInfo() + + client_info = meta.get(META_CLIENT_INFO_KEY) + if isinstance(client_info, Mapping): + info.client_name = _non_empty_str(client_info.get("name")) + info.client_version = _non_empty_str(client_info.get("version")) + elif client_info is not None: + info.client_name = _non_empty_str(getattr(client_info, "name", None)) + info.client_version = _non_empty_str(getattr(client_info, "version", None)) + + info.protocol_version = _non_empty_str(meta.get(META_PROTOCOL_VERSION_KEY)) + + if not (info.client_name or info.client_version or info.protocol_version): + return None + return info + + +def apply_meta_client_info( + source: Any, + client_name: Optional[str], + client_version: Optional[str], + protocol_version: Optional[str], +) -> Tuple[Optional[str], Optional[str], Optional[str]]: + """Layer any ``_meta`` client identity over values resolved from the transport + or the session token. + + ``_meta`` wins: it is the per-request truth under the 2026-07-28 revision, + where there is no ``initialize`` and no session token to learn from. Only the + fields the request actually carries are overridden, so a legacy request + (no ``_meta``) leaves all three untouched.""" + info = read_meta_client_info(source) + if info is None: + return client_name, client_version, protocol_version + return ( + info.client_name or client_name, + info.client_version or client_version, + info.protocol_version or protocol_version, + ) diff --git a/posthog/mcp/_instrument_fastmcp.py b/posthog/mcp/_instrument_fastmcp.py index 6fd871f2..69798461 100644 --- a/posthog/mcp/_instrument_fastmcp.py +++ b/posthog/mcp/_instrument_fastmcp.py @@ -95,7 +95,11 @@ async def wrapped( mcp_session_id = _mcp_session_id(context) token, client_name, client_version, protocol_version = ( resolve_session_and_client( - mcp_session_id, client_name, client_version, protocol_version + mcp_session_id, + client_name, + client_version, + protocol_version, + meta_source=getattr(context, "request_context", None), ) ) request = build_tool_call_request(name, arguments) @@ -226,7 +230,11 @@ async def list_handler(req: Any) -> Any: mcp_session_id = _low_level_session_id(server) token, client_name, client_version, protocol_version = ( resolve_session_and_client( - mcp_session_id, client_name, client_version, protocol_version + mcp_session_id, + client_name, + client_version, + protocol_version, + meta_source=req, ) ) request = request_to_dict(req) diff --git a/posthog/mcp/_instrument_lowlevel.py b/posthog/mcp/_instrument_lowlevel.py index cbe6c500..d06c2af1 100644 --- a/posthog/mcp/_instrument_lowlevel.py +++ b/posthog/mcp/_instrument_lowlevel.py @@ -98,7 +98,11 @@ async def handler(req: Any) -> Any: mcp_session_id = _mcp_session_id(server) token, client_name, client_version, protocol_version = ( resolve_session_and_client( - mcp_session_id, client_name, client_version, protocol_version + mcp_session_id, + client_name, + client_version, + protocol_version, + meta_source=req, ) ) request = build_tool_call_request(name, arguments) @@ -238,7 +242,11 @@ async def handler(req: Any) -> Any: mcp_session_id = _mcp_session_id(server) token, client_name, client_version, protocol_version = ( resolve_session_and_client( - mcp_session_id, client_name, client_version, protocol_version + mcp_session_id, + client_name, + client_version, + protocol_version, + meta_source=req, ) ) request = request_to_dict(req) diff --git a/posthog/mcp/_instrumentation.py b/posthog/mcp/_instrumentation.py index 865e5473..2223388a 100644 --- a/posthog/mcp/_instrumentation.py +++ b/posthog/mcp/_instrumentation.py @@ -16,6 +16,7 @@ from typing import Any, Dict, List, Optional, Set from ._capture import capture_event +from ._client_identity import apply_meta_client_info from ._event_types import MCPAnalyticsEventType from ._exceptions import capture_exception from ._intent import resolve_tool_call_intent, set_event_intent @@ -191,12 +192,19 @@ def resolve_session_and_client( client_name: Optional[str], client_version: Optional[str], protocol_version: Optional[str] = None, + meta_source: Any = None, ) -> tuple[Optional[SessionTokenPayload], Optional[str], Optional[str], Optional[str]]: """Decode a replayed ``Mcp-Session-Id`` value as a self-encoded session token, and backfill the client name/version/protocol version from it when the live transport supplied none (the stateless-pod case, where ``initialize`` was never seen here). + ``meta_source`` is anything carrying this request's ``params._meta`` (a + ``Context``, ``RequestContext``, or request object). Under the MCP 2026-07-28 + revision there is no ``initialize`` and no session token, so ``_meta`` is the + only source of client identity — it therefore takes precedence over both. For + legacy clients it is absent and nothing changes. + Returns ``(token, client_name, client_version, protocol_version)``; ``token`` is ``None`` when the header isn't one of our tokens (a plain UUID, JWT, or nothing).""" token = decode_session_id(raw_session_id) @@ -204,7 +212,12 @@ def resolve_session_and_client( client_name = client_name or token.client_name client_version = client_version or token.client_version protocol_version = protocol_version or token.protocol_version - return token, client_name, client_version, protocol_version + return ( + token, + *apply_meta_client_info( + meta_source, client_name, client_version, protocol_version + ), + ) async def prepare_request( diff --git a/posthog/test/mcp/test_client_identity.py b/posthog/test/mcp/test_client_identity.py new file mode 100644 index 00000000..b8c96277 --- /dev/null +++ b/posthog/test/mcp/test_client_identity.py @@ -0,0 +1,282 @@ +"""Client identity read from a request's ``_meta`` (MCP 2026-07-28 revision). + +The 2026-07-28 stateless revision drops the ``initialize`` handshake and the +``Mcp-Session-Id`` header, so client name/version and protocol version arrive in +every request's ``params._meta`` instead. These tests cover the reader, the +precedence rules against the session token, and an end-to-end pass through the +low-level adapter. +""" + +import mcp.types as mcp_types + +from posthog.mcp._client_identity import ( + META_CLIENT_INFO_KEY, + META_PROTOCOL_VERSION_KEY, + apply_meta_client_info, + read_meta_client_info, +) +from posthog.mcp._instrumentation import resolve_session_and_client +from posthog.mcp.session_token import encode_session_id, SessionTokenPayload +from posthog.test.mcp._helpers import ( + FakeClient, + events_named as _events, + flush_background as _flush, +) + +FULL_META = { + META_CLIENT_INFO_KEY: {"name": "codex", "version": "1.2.3"}, + META_PROTOCOL_VERSION_KEY: "2026-07-28", +} + + +def call_request(meta=None): + """A real CallToolRequest, so we exercise the pydantic `extra="allow"` path + the SDK actually hands us rather than a hand-rolled stand-in.""" + params = {"name": "echo", "arguments": {"a": 1}} + if meta is not None: + params["_meta"] = meta + return mcp_types.CallToolRequest.model_validate( + {"method": "tools/call", "params": params} + ) + + +# --- read_meta_client_info --------------------------------------------------- + + +def test_reads_client_info_and_protocol_version_from_meta(): + info = read_meta_client_info(call_request(FULL_META)) + assert info.client_name == "codex" + assert info.client_version == "1.2.3" + assert info.protocol_version == "2026-07-28" + + +def test_returns_none_when_meta_absent(): + assert read_meta_client_info(call_request()) is None + assert read_meta_client_info(None) is None + assert read_meta_client_info({"method": "tools/call"}) is None + + +def test_returns_none_when_meta_has_no_recognized_keys(): + assert read_meta_client_info(call_request({"com.other/thing": 1})) is None + + +def test_ignores_empty_and_non_string_fields(): + info = read_meta_client_info( + call_request( + { + META_CLIENT_INFO_KEY: {"name": "", "version": 42}, + META_PROTOCOL_VERSION_KEY: "", + } + ) + ) + assert info is None + + +def test_reads_a_partial_protocol_version_only(): + info = read_meta_client_info( + call_request({META_PROTOCOL_VERSION_KEY: "2026-07-28"}) + ) + assert info.protocol_version == "2026-07-28" + assert info.client_name is None + assert info.client_version is None + + +def test_progress_token_alongside_client_info_is_ignored(): + """`progressToken` is a declared field, so it lands outside `model_extra` — + make sure its presence doesn't shadow the reverse-DNS keys.""" + info = read_meta_client_info(call_request({**FULL_META, "progressToken": "p1"})) + assert info.client_name == "codex" + assert info.protocol_version == "2026-07-28" + + +def test_reads_from_a_list_tools_request(): + req = mcp_types.ListToolsRequest.model_validate( + {"method": "tools/list", "params": {"_meta": FULL_META}} + ) + assert read_meta_client_info(req).client_name == "codex" + + +def test_reads_from_plain_dict_shapes(): + """The reader takes whatever the call sites have on hand: a raw JSON-RPC dict + (`_meta`), a `request_to_dict()` dump (which drops the alias to `meta`), or + the meta mapping itself.""" + assert ( + read_meta_client_info({"params": {"_meta": FULL_META}}).client_name == "codex" + ) + assert read_meta_client_info({"params": {"meta": FULL_META}}).client_name == "codex" + assert read_meta_client_info(FULL_META).client_name == "codex" + + +def test_reads_through_a_request_context_like_object(): + class Ctx: + def __init__(self, meta): + self.meta = meta + + class Context: + def __init__(self, meta): + self.request_context = Ctx(meta) + + assert read_meta_client_info(Context(FULL_META)).client_name == "codex" + + +def test_never_raises_on_a_hostile_source(): + class Exploding: + @property + def meta(self): + raise RuntimeError("off-request access") + + assert read_meta_client_info(Exploding()) is None + + +# --- precedence -------------------------------------------------------------- + + +def test_meta_overrides_transport_derived_values(): + name, version, protocol = apply_meta_client_info( + call_request(FULL_META), "legacy-client", "0.0.1", "2025-11-25" + ) + assert (name, version, protocol) == ("codex", "1.2.3", "2026-07-28") + + +def test_absent_meta_leaves_existing_values_untouched(): + name, version, protocol = apply_meta_client_info( + call_request(), "legacy-client", "0.0.1", "2025-11-25" + ) + assert (name, version, protocol) == ("legacy-client", "0.0.1", "2025-11-25") + + +def test_partial_meta_only_overrides_what_it_carries(): + name, version, protocol = apply_meta_client_info( + call_request({META_PROTOCOL_VERSION_KEY: "2026-07-28"}), + "legacy-client", + "0.0.1", + "2025-11-25", + ) + assert (name, version, protocol) == ("legacy-client", "0.0.1", "2026-07-28") + + +def test_meta_wins_over_the_session_token(): + """A stale token from an earlier session must not shadow the identity the + client is asserting on this request.""" + token = encode_session_id( + SessionTokenPayload( + session_id="ses_abc", + client_name="stale-client", + client_version="0.0.1", + protocol_version="2025-11-25", + ) + ) + decoded, name, version, protocol = resolve_session_and_client( + token, None, None, None, meta_source=call_request(FULL_META) + ) + assert decoded is not None and decoded.session_id == "ses_abc" + assert (name, version, protocol) == ("codex", "1.2.3", "2026-07-28") + + +def test_token_still_backfills_when_meta_is_absent(): + token = encode_session_id( + SessionTokenPayload( + session_id="ses_abc", + client_name="claude", + client_version="2.0.0", + protocol_version="2025-11-25", + ) + ) + _, name, version, protocol = resolve_session_and_client( + token, None, None, None, meta_source=call_request() + ) + assert (name, version, protocol) == ("claude", "2.0.0", "2025-11-25") + + +def test_two_concurrent_requests_do_not_cross_attribute(): + """Identity is resolved per request, so one client's `_meta` can never leak + into a sibling request on the same multiplexed server.""" + a = apply_meta_client_info( + call_request({META_CLIENT_INFO_KEY: {"name": "codex", "version": "1.0.0"}}), + None, + None, + None, + ) + b = apply_meta_client_info( + call_request({META_CLIENT_INFO_KEY: {"name": "claude", "version": "2.0.0"}}), + None, + None, + None, + ) + assert a[0] == "codex" + assert b[0] == "claude" + + +# --- end to end -------------------------------------------------------------- + + +async def test_low_level_tool_call_stamps_meta_identity_on_events(): + """No initialize, no session token — the 2026-07-28 shape. Client identity + must still reach the captured event.""" + from mcp.server.lowlevel import Server + + from posthog.mcp import instrument + + server = Server("test-server") + + @server.call_tool() + async def call_tool(name: str, arguments: dict): + return [mcp_types.TextContent(type="text", text="ok")] + + client = FakeClient() + instrument(server, client) + + handler = server.request_handlers[mcp_types.CallToolRequest] + await handler(call_request(FULL_META)) + await _flush() + + calls = _events(client, "$mcp_tool_call") + assert calls + props = calls[0]["properties"] + assert props["$mcp_client_name"] == "codex" + assert props["$mcp_client_version"] == "1.2.3" + assert props["$mcp_protocol_version"] == "2026-07-28" + + # The synthesized initialize for the same session carries it too. + init = _events(client, "$mcp_initialize") + assert init + assert init[0]["properties"]["$mcp_client_name"] == "codex" + + +async def test_fastmcp_tool_call_stamps_meta_identity_from_request_context(): + """The FastMCP call path has no request object — only a ``Context`` — so it + reads ``_meta`` off ``context.request_context``.""" + from mcp.server.fastmcp import FastMCP + + from posthog.mcp import instrument + + class StubRequestContext: + def __init__(self, meta): + self.meta = meta + + class StubContext: + def __init__(self, meta): + self.request_context = StubRequestContext(meta) + + server = FastMCP("test-server") + + @server.tool() + def add(a: int, b: int) -> int: + return a + b + + client = FakeClient() + instrument(server, client) + + await server._tool_manager.call_tool( + "add", + {"a": 2, "b": 3, "context": "summing for the report"}, + context=StubContext(FULL_META), + ) + await _flush() + + calls = _events(client, "$mcp_tool_call") + assert calls + props = calls[0]["properties"] + assert props["$mcp_client_name"] == "codex" + assert props["$mcp_client_version"] == "1.2.3" + assert props["$mcp_protocol_version"] == "2026-07-28" From 882fc790912710a24f24f2c6cd7937278100b79e Mon Sep 17 00:00:00 2001 From: Georgis Andonis Date: Thu, 30 Jul 2026 17:09:33 +0300 Subject: [PATCH 2/7] refactor(mcp): simplify the _meta client-identity reader MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first cut generalized over shapes that never reach it: a recursive walk with a depth cap, plain-dict handling, an attribute-based fallback for clientInfo, and a MetaClientInfo dataclass fronted by a second function. Only two shapes actually arrive — a RequestContext (carries `.meta`) and a request object (nests it under `.params`) — and both expose the reverse-DNS keys as a plain dict via `model_extra`. Collapse to a single apply_meta_client_info() over those two, dropping the module from 136 to 68 lines and the tests from 18 to 15 without losing a real case. Also fixes a bug the simplification surfaced: the FastMCP call site used getattr(context, "request_context", None), but that property *raises* when a Context is used off-request, and a getattr default only covers AttributeError. The ValueError propagated into the caller's tool call. Route it through a try/except accessor instead, matching the file's other defensive accessors, and cover it with a regression test (verified failing against the old call site). Generated-By: PostHog Code Task-Id: 14a95eda-2573-4c9a-9191-d5eecb8e8d44 --- posthog/mcp/_client_identity.py | 140 ++++------------ posthog/mcp/_instrument_fastmcp.py | 11 +- posthog/test/mcp/test_client_identity.py | 204 +++++++++++------------ 3 files changed, 139 insertions(+), 216 deletions(-) diff --git a/posthog/mcp/_client_identity.py b/posthog/mcp/_client_identity.py index 7407cb40..cf345963 100644 --- a/posthog/mcp/_client_identity.py +++ b/posthog/mcp/_client_identity.py @@ -1,116 +1,42 @@ """Client identity under the MCP 2026-07-28 (stateless) revision. That revision removes the ``initialize`` handshake and the ``Mcp-Session-Id`` -header (SEP-2575 / SEP-2567). Client name/version and the protocol version no -longer arrive once at ``initialize`` — they travel in every request's -``params._meta`` under the reverse-DNS keys below. We mirror the literal key -strings here rather than depend on the (still beta) v2 SDK, which is not a +header (SEP-2575 / SEP-2567), so the client name/version and the protocol +version travel in every request's ``params._meta`` under the reverse-DNS keys +below instead of arriving once at ``initialize``. We mirror the literal key +strings rather than depend on the (still beta) v2 SDK, which is not a dependency of this package. - -Reading them per request also keeps identity correct when one instrumented -server multiplexes concurrent requests from different clients, which the -stateless spec allows: each request resolves its own identity instead of -sharing server-wide state. """ from __future__ import annotations -from dataclasses import dataclass from typing import Any, Mapping, Optional, Tuple META_CLIENT_INFO_KEY = "io.modelcontextprotocol/clientInfo" META_PROTOCOL_VERSION_KEY = "io.modelcontextprotocol/protocolVersion" -# Depth cap for the source walk below; the real nesting is at most -# context -> request_context -> params -> meta. -_MAX_UNWRAP_DEPTH = 6 - - -@dataclass -class MetaClientInfo: - """Whatever slice of the client identity a request's ``_meta`` carried.""" - client_name: Optional[str] = None - client_version: Optional[str] = None - protocol_version: Optional[str] = None - - -def _non_empty_str(value: Any) -> Optional[str]: - """Keep only non-empty strings — a client sending ``""`` or a non-string - shouldn't blank out an otherwise-good value from the token or ``initialize``.""" +def _text(value: Any) -> Optional[str]: + """Non-empty strings only, so a client sending ``""`` or a non-string can't + blank out a good value from the transport or the session token.""" return value if isinstance(value, str) and value else None -def _extract_meta(source: Any, depth: int = 0) -> Optional[Mapping[str, Any]]: - """Best-effort walk to the ``_meta`` mapping. - - Accepts any of the shapes the call sites have on hand: a FastMCP ``Context``, - a low-level ``RequestContext``, an MCP request object, a request params - object, the ``RequestParams.Meta`` model itself, or a plain dict of any of - those. Returns ``None`` when the source carries no ``_meta``.""" - if source is None or depth > _MAX_UNWRAP_DEPTH: - return None - - if isinstance(source, Mapping): - # A JSON-RPC-ish request dict: descend into its params. - if "params" in source or "method" in source: - return _extract_meta(source.get("params"), depth + 1) - # Params: the wire spells this `_meta`, but `request_to_dict` dumps - # pydantic params without `by_alias`, so it comes through as `meta`. - for key in ("_meta", "meta"): - nested = source.get(key) - if isinstance(nested, Mapping): - return nested - # Otherwise assume we were handed the meta mapping directly. - return source - - # Descend toward the meta object first. Request and params models are - # `extra="allow"` as well, so checking `model_extra` before descending would - # match the wrong (and normally empty) level and stop the walk early. - for attr in ("meta", "params", "request_context"): - try: - nested = getattr(source, attr, None) - except Exception: # noqa: BLE001 - property access can raise off-request - nested = None - if nested is not None and nested is not source: - found = _extract_meta(nested, depth + 1) - if found is not None: - return found +def _meta_entries(source: Any) -> Optional[Mapping[str, Any]]: + """The request's ``_meta`` entries, or ``None``. - # `RequestParams.Meta` is `extra="allow"`, so the reverse-DNS keys — which - # aren't declared fields — land in `model_extra`. - extra = getattr(source, "model_extra", None) - if isinstance(extra, Mapping) and extra: - return extra - return None - - -def read_meta_client_info(source: Any) -> Optional[MetaClientInfo]: - """Read the client name/version and protocol version a modern client puts in - ``params._meta``. Returns ``None`` when the request carries none (e.g. a - legacy client, which sends this at ``initialize`` instead). Never raises.""" + ``source`` is either a ``RequestContext`` (which carries it as ``.meta``) or + a request object (which nests it under ``.params``).""" try: - meta = _extract_meta(source) + meta = getattr(source, "meta", None) or getattr( + getattr(source, "params", None), "meta", None + ) + # `RequestParams.Meta` is `extra="allow"`, and these reverse-DNS keys + # aren't declared fields, so they land in `model_extra`. + extra = getattr(meta, "model_extra", None) except Exception: # noqa: BLE001 - identity is best-effort, never fatal return None - if not isinstance(meta, Mapping): - return None - - info = MetaClientInfo() - - client_info = meta.get(META_CLIENT_INFO_KEY) - if isinstance(client_info, Mapping): - info.client_name = _non_empty_str(client_info.get("name")) - info.client_version = _non_empty_str(client_info.get("version")) - elif client_info is not None: - info.client_name = _non_empty_str(getattr(client_info, "name", None)) - info.client_version = _non_empty_str(getattr(client_info, "version", None)) - - info.protocol_version = _non_empty_str(meta.get(META_PROTOCOL_VERSION_KEY)) - - if not (info.client_name or info.client_version or info.protocol_version): - return None - return info + return extra if isinstance(extra, Mapping) else None def apply_meta_client_info( @@ -119,18 +45,24 @@ def apply_meta_client_info( client_version: Optional[str], protocol_version: Optional[str], ) -> Tuple[Optional[str], Optional[str], Optional[str]]: - """Layer any ``_meta`` client identity over values resolved from the transport - or the session token. - - ``_meta`` wins: it is the per-request truth under the 2026-07-28 revision, - where there is no ``initialize`` and no session token to learn from. Only the - fields the request actually carries are overridden, so a legacy request - (no ``_meta``) leaves all three untouched.""" - info = read_meta_client_info(source) - if info is None: + """Layer any client identity from this request's ``_meta`` over values already + resolved from the transport or the session token. + + ``_meta`` wins: under the 2026-07-28 revision there is no ``initialize`` and + no session token to learn from, so it is the only per-request truth. Only the + fields the request actually carries override, so a legacy request (no + ``_meta``) leaves all three untouched.""" + meta = _meta_entries(source) + if not meta: return client_name, client_version, protocol_version + + client_info = meta.get(META_CLIENT_INFO_KEY) + if isinstance(client_info, Mapping): + client_name = _text(client_info.get("name")) or client_name + client_version = _text(client_info.get("version")) or client_version + return ( - info.client_name or client_name, - info.client_version or client_version, - info.protocol_version or protocol_version, + client_name, + client_version, + _text(meta.get(META_PROTOCOL_VERSION_KEY)) or protocol_version, ) diff --git a/posthog/mcp/_instrument_fastmcp.py b/posthog/mcp/_instrument_fastmcp.py index 69798461..672aec5c 100644 --- a/posthog/mcp/_instrument_fastmcp.py +++ b/posthog/mcp/_instrument_fastmcp.py @@ -99,7 +99,7 @@ async def wrapped( client_name, client_version, protocol_version, - meta_source=getattr(context, "request_context", None), + meta_source=_context_request_context(context), ) ) request = build_tool_call_request(name, arguments) @@ -421,6 +421,15 @@ def _low_level_protocol_version(server: Any) -> Optional[str]: return None +def _context_request_context(context: Any) -> Any: + """``Context.request_context`` raises off-request rather than returning + ``None``, so a bare ``getattr`` default isn't enough.""" + try: + return context.request_context + except Exception: # noqa: BLE001 + return None + + def _client_info(context: Any) -> Tuple[Optional[str], Optional[str]]: try: client_params = context.request_context.session.client_params diff --git a/posthog/test/mcp/test_client_identity.py b/posthog/test/mcp/test_client_identity.py index b8c96277..a89b0dc0 100644 --- a/posthog/test/mcp/test_client_identity.py +++ b/posthog/test/mcp/test_client_identity.py @@ -1,22 +1,22 @@ """Client identity read from a request's ``_meta`` (MCP 2026-07-28 revision). The 2026-07-28 stateless revision drops the ``initialize`` handshake and the -``Mcp-Session-Id`` header, so client name/version and protocol version arrive in -every request's ``params._meta`` instead. These tests cover the reader, the -precedence rules against the session token, and an end-to-end pass through the -low-level adapter. +``Mcp-Session-Id`` header, so the client name/version and protocol version +arrive in every request's ``params._meta`` instead. These tests cover the +precedence rules against the session token and end-to-end passes through both +adapters. """ import mcp.types as mcp_types +from posthog.mcp import instrument from posthog.mcp._client_identity import ( META_CLIENT_INFO_KEY, META_PROTOCOL_VERSION_KEY, apply_meta_client_info, - read_meta_client_info, ) from posthog.mcp._instrumentation import resolve_session_and_client -from posthog.mcp.session_token import encode_session_id, SessionTokenPayload +from posthog.mcp.session_token import SessionTokenPayload, encode_session_id from posthog.test.mcp._helpers import ( FakeClient, events_named as _events, @@ -30,8 +30,8 @@ def call_request(meta=None): - """A real CallToolRequest, so we exercise the pydantic `extra="allow"` path - the SDK actually hands us rather than a hand-rolled stand-in.""" + """A real ``CallToolRequest``, so we exercise the pydantic ``extra="allow"`` + path the SDK actually hands us rather than a hand-rolled stand-in.""" params = {"name": "echo", "arguments": {"a": 1}} if meta is not None: params["_meta"] = meta @@ -40,119 +40,89 @@ def call_request(meta=None): ) -# --- read_meta_client_info --------------------------------------------------- +def apply(meta, name=None, version=None, protocol=None): + return apply_meta_client_info(call_request(meta), name, version, protocol) + + +# --- reading ----------------------------------------------------------------- def test_reads_client_info_and_protocol_version_from_meta(): - info = read_meta_client_info(call_request(FULL_META)) - assert info.client_name == "codex" - assert info.client_version == "1.2.3" - assert info.protocol_version == "2026-07-28" + assert apply(FULL_META) == ("codex", "1.2.3", "2026-07-28") -def test_returns_none_when_meta_absent(): - assert read_meta_client_info(call_request()) is None - assert read_meta_client_info(None) is None - assert read_meta_client_info({"method": "tools/call"}) is None +def test_ignores_a_request_without_meta(): + assert apply(None, "legacy", "0.0.1", "2025-11-25") == ( + "legacy", + "0.0.1", + "2025-11-25", + ) -def test_returns_none_when_meta_has_no_recognized_keys(): - assert read_meta_client_info(call_request({"com.other/thing": 1})) is None +def test_ignores_meta_without_the_recognized_keys(): + assert apply({"com.other/thing": 1}) == (None, None, None) def test_ignores_empty_and_non_string_fields(): - info = read_meta_client_info( - call_request( - { - META_CLIENT_INFO_KEY: {"name": "", "version": 42}, - META_PROTOCOL_VERSION_KEY: "", - } - ) + """A client sending `""` or a non-string must not blank out a good value.""" + meta = { + META_CLIENT_INFO_KEY: {"name": "", "version": 42}, + META_PROTOCOL_VERSION_KEY: "", + } + assert apply(meta, "legacy", "0.0.1", "2025-11-25") == ( + "legacy", + "0.0.1", + "2025-11-25", ) - assert info is None -def test_reads_a_partial_protocol_version_only(): - info = read_meta_client_info( - call_request({META_PROTOCOL_VERSION_KEY: "2026-07-28"}) - ) - assert info.protocol_version == "2026-07-28" - assert info.client_name is None - assert info.client_version is None +def test_only_overrides_the_fields_the_request_carries(): + assert apply( + {META_PROTOCOL_VERSION_KEY: "2026-07-28"}, "legacy", "0.0.1", "2025-11-25" + ) == ("legacy", "0.0.1", "2026-07-28") def test_progress_token_alongside_client_info_is_ignored(): """`progressToken` is a declared field, so it lands outside `model_extra` — make sure its presence doesn't shadow the reverse-DNS keys.""" - info = read_meta_client_info(call_request({**FULL_META, "progressToken": "p1"})) - assert info.client_name == "codex" - assert info.protocol_version == "2026-07-28" + assert apply({**FULL_META, "progressToken": "p1"}) == ( + "codex", + "1.2.3", + "2026-07-28", + ) def test_reads_from_a_list_tools_request(): req = mcp_types.ListToolsRequest.model_validate( {"method": "tools/list", "params": {"_meta": FULL_META}} ) - assert read_meta_client_info(req).client_name == "codex" + assert apply_meta_client_info(req, None, None, None)[0] == "codex" -def test_reads_from_plain_dict_shapes(): - """The reader takes whatever the call sites have on hand: a raw JSON-RPC dict - (`_meta`), a `request_to_dict()` dump (which drops the alias to `meta`), or - the meta mapping itself.""" - assert ( - read_meta_client_info({"params": {"_meta": FULL_META}}).client_name == "codex" - ) - assert read_meta_client_info({"params": {"meta": FULL_META}}).client_name == "codex" - assert read_meta_client_info(FULL_META).client_name == "codex" - +def test_reads_from_a_request_context(): + """The FastMCP call path passes a ``RequestContext``, which carries the meta + directly rather than nesting it under ``params``.""" -def test_reads_through_a_request_context_like_object(): class Ctx: - def __init__(self, meta): - self.meta = meta - - class Context: - def __init__(self, meta): - self.request_context = Ctx(meta) - - assert read_meta_client_info(Context(FULL_META)).client_name == "codex" - + meta = mcp_types.RequestParams.Meta.model_validate(FULL_META) -def test_never_raises_on_a_hostile_source(): - class Exploding: - @property - def meta(self): - raise RuntimeError("off-request access") - - assert read_meta_client_info(Exploding()) is None - - -# --- precedence -------------------------------------------------------------- - - -def test_meta_overrides_transport_derived_values(): - name, version, protocol = apply_meta_client_info( - call_request(FULL_META), "legacy-client", "0.0.1", "2025-11-25" + assert apply_meta_client_info(Ctx(), None, None, None) == ( + "codex", + "1.2.3", + "2026-07-28", ) - assert (name, version, protocol) == ("codex", "1.2.3", "2026-07-28") -def test_absent_meta_leaves_existing_values_untouched(): - name, version, protocol = apply_meta_client_info( - call_request(), "legacy-client", "0.0.1", "2025-11-25" +def test_never_raises_on_an_unusable_source(): + assert apply_meta_client_info(None, "legacy", None, None) == ("legacy", None, None) + assert apply_meta_client_info(object(), "legacy", None, None) == ( + "legacy", + None, + None, ) - assert (name, version, protocol) == ("legacy-client", "0.0.1", "2025-11-25") -def test_partial_meta_only_overrides_what_it_carries(): - name, version, protocol = apply_meta_client_info( - call_request({META_PROTOCOL_VERSION_KEY: "2026-07-28"}), - "legacy-client", - "0.0.1", - "2025-11-25", - ) - assert (name, version, protocol) == ("legacy-client", "0.0.1", "2026-07-28") +# --- precedence -------------------------------------------------------------- def test_meta_wins_over_the_session_token(): @@ -191,18 +161,8 @@ def test_token_still_backfills_when_meta_is_absent(): def test_two_concurrent_requests_do_not_cross_attribute(): """Identity is resolved per request, so one client's `_meta` can never leak into a sibling request on the same multiplexed server.""" - a = apply_meta_client_info( - call_request({META_CLIENT_INFO_KEY: {"name": "codex", "version": "1.0.0"}}), - None, - None, - None, - ) - b = apply_meta_client_info( - call_request({META_CLIENT_INFO_KEY: {"name": "claude", "version": "2.0.0"}}), - None, - None, - None, - ) + a = apply({META_CLIENT_INFO_KEY: {"name": "codex", "version": "1.0.0"}}) + b = apply({META_CLIENT_INFO_KEY: {"name": "claude", "version": "2.0.0"}}) assert a[0] == "codex" assert b[0] == "claude" @@ -215,8 +175,6 @@ async def test_low_level_tool_call_stamps_meta_identity_on_events(): must still reach the captured event.""" from mcp.server.lowlevel import Server - from posthog.mcp import instrument - server = Server("test-server") @server.call_tool() @@ -244,19 +202,15 @@ async def call_tool(name: str, arguments: dict): async def test_fastmcp_tool_call_stamps_meta_identity_from_request_context(): - """The FastMCP call path has no request object — only a ``Context`` — so it - reads ``_meta`` off ``context.request_context``.""" + """The FastMCP call path has no request object — only a ``Context``.""" from mcp.server.fastmcp import FastMCP - from posthog.mcp import instrument - - class StubRequestContext: - def __init__(self, meta): - self.meta = meta - class StubContext: - def __init__(self, meta): - self.request_context = StubRequestContext(meta) + """Stands in for a FastMCP ``Context``; only ``request_context.meta`` is + read on this path.""" + + class request_context: # noqa: N801 - mirrors the attribute it fakes + meta = mcp_types.RequestParams.Meta.model_validate(FULL_META) server = FastMCP("test-server") @@ -270,7 +224,7 @@ def add(a: int, b: int) -> int: await server._tool_manager.call_tool( "add", {"a": 2, "b": 3, "context": "summing for the report"}, - context=StubContext(FULL_META), + context=StubContext(), ) await _flush() @@ -280,3 +234,31 @@ def add(a: int, b: int) -> int: assert props["$mcp_client_name"] == "codex" assert props["$mcp_client_version"] == "1.2.3" assert props["$mcp_protocol_version"] == "2026-07-28" + + +async def test_fastmcp_tool_call_survives_a_context_used_off_request(): + """`Context.request_context` raises off-request; that must not surface as a + tool-call failure.""" + from mcp.server.fastmcp import FastMCP + + class ExplodingContext: + @property + def request_context(self): + raise ValueError("Context is not available outside of a request") + + server = FastMCP("test-server") + + @server.tool() + def add(a: int, b: int) -> int: + return a + b + + client = FakeClient() + instrument(server, client) + + result = await server._tool_manager.call_tool( + "add", {"a": 2, "b": 3}, context=ExplodingContext() + ) + await _flush() + + assert result == 5 + assert _events(client, "$mcp_tool_call") From b8bd82aac372bf0d45f05e2a7dc3c75fc1f17560 Mon Sep 17 00:00:00 2001 From: Georgis Andonis Date: Thu, 30 Jul 2026 17:14:41 +0300 Subject: [PATCH 3/7] refactor(mcp): read _meta as the dict it is Once the reverse-DNS keys are in hand, reading them is dict access; the surrounding structure wasn't earning its keep. Drop `_meta_entries()` and the isinstance ladder for a single try/except around three `meta.get(...)` calls. The one genuinely load-bearing step is that `_meta` is not a dict when it reaches us: it arrives as a `RequestParams.Meta`, and because that model is `extra="allow"` the reverse-DNS keys land in `model_extra` rather than on the model. That's one getattr, now commented in place. `_meta` is arbitrary client-controlled JSON, so a `clientInfo` that isn't an object would raise on `.get`; the try/except covers every malformed shape at once instead of type-checking each field, and is pinned by a test. Module is 136 -> 54 lines across the two passes. Generated-By: PostHog Code Task-Id: 14a95eda-2573-4c9a-9191-d5eecb8e8d44 --- posthog/mcp/_client_identity.py | 52 +++++++++--------------- posthog/test/mcp/test_client_identity.py | 11 +++++ 2 files changed, 30 insertions(+), 33 deletions(-) diff --git a/posthog/mcp/_client_identity.py b/posthog/mcp/_client_identity.py index cf345963..02551f1a 100644 --- a/posthog/mcp/_client_identity.py +++ b/posthog/mcp/_client_identity.py @@ -10,35 +10,18 @@ from __future__ import annotations -from typing import Any, Mapping, Optional, Tuple +from typing import Any, Optional, Tuple META_CLIENT_INFO_KEY = "io.modelcontextprotocol/clientInfo" META_PROTOCOL_VERSION_KEY = "io.modelcontextprotocol/protocolVersion" -def _text(value: Any) -> Optional[str]: +def _str(value: Any) -> Optional[str]: """Non-empty strings only, so a client sending ``""`` or a non-string can't - blank out a good value from the transport or the session token.""" + blank out (or put junk over) a good value from the token or ``initialize``.""" return value if isinstance(value, str) and value else None -def _meta_entries(source: Any) -> Optional[Mapping[str, Any]]: - """The request's ``_meta`` entries, or ``None``. - - ``source`` is either a ``RequestContext`` (which carries it as ``.meta``) or - a request object (which nests it under ``.params``).""" - try: - meta = getattr(source, "meta", None) or getattr( - getattr(source, "params", None), "meta", None - ) - # `RequestParams.Meta` is `extra="allow"`, and these reverse-DNS keys - # aren't declared fields, so they land in `model_extra`. - extra = getattr(meta, "model_extra", None) - except Exception: # noqa: BLE001 - identity is best-effort, never fatal - return None - return extra if isinstance(extra, Mapping) else None - - def apply_meta_client_info( source: Any, client_name: Optional[str], @@ -52,17 +35,20 @@ def apply_meta_client_info( no session token to learn from, so it is the only per-request truth. Only the fields the request actually carries override, so a legacy request (no ``_meta``) leaves all three untouched.""" - meta = _meta_entries(source) - if not meta: + try: + # A RequestContext carries the meta directly; a request nests it under + # `params`. Either way it's a `RequestParams.Meta`, which is + # `extra="allow"` — these keys aren't declared fields, so they land in + # `model_extra`. + meta = getattr(source, "meta", None) or getattr( + getattr(source, "params", None), "meta", None + ) + meta = getattr(meta, "model_extra", None) or {} + client_info = meta.get(META_CLIENT_INFO_KEY) or {} + return ( + _str(client_info.get("name")) or client_name, + _str(client_info.get("version")) or client_version, + _str(meta.get(META_PROTOCOL_VERSION_KEY)) or protocol_version, + ) + except Exception: # noqa: BLE001 - `_meta` is client-controlled; never fatal return client_name, client_version, protocol_version - - client_info = meta.get(META_CLIENT_INFO_KEY) - if isinstance(client_info, Mapping): - client_name = _text(client_info.get("name")) or client_name - client_version = _text(client_info.get("version")) or client_version - - return ( - client_name, - client_version, - _text(meta.get(META_PROTOCOL_VERSION_KEY)) or protocol_version, - ) diff --git a/posthog/test/mcp/test_client_identity.py b/posthog/test/mcp/test_client_identity.py index a89b0dc0..f9d7b541 100644 --- a/posthog/test/mcp/test_client_identity.py +++ b/posthog/test/mcp/test_client_identity.py @@ -122,6 +122,17 @@ def test_never_raises_on_an_unusable_source(): ) +def test_falls_back_on_a_malformed_client_info(): + """`_meta` is arbitrary client-controlled JSON, so `clientInfo` need not be an + object; the wrong shape must fall back rather than raise.""" + for bad in ([1, 2], "codex", 7): + assert apply({META_CLIENT_INFO_KEY: bad}, "legacy", "0.0.1", "2025-11-25") == ( + "legacy", + "0.0.1", + "2025-11-25", + ) + + # --- precedence -------------------------------------------------------------- From 8719bddfe2ec3a49802e854eea7b4b06e9af3194 Mon Sep 17 00:00:00 2001 From: Georgis Andonis Date: Thu, 30 Jul 2026 17:17:40 +0300 Subject: [PATCH 4/7] refactor(mcp): flatten the walk to the _meta dict MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Separate the two concerns visually: three lines to get from the pydantic model to the dict, then plain `.get()` reads. No behavior change. Records why `model_extra` rather than the more obvious `params.model_dump(by_alias=True)["_meta"]`: the latter serializes the entire params payload — tool arguments included — to read two fields, measured 80x slower on a 20KB argument. Generated-By: PostHog Code Task-Id: 14a95eda-2573-4c9a-9191-d5eecb8e8d44 --- posthog/mcp/_client_identity.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/posthog/mcp/_client_identity.py b/posthog/mcp/_client_identity.py index 02551f1a..8288a203 100644 --- a/posthog/mcp/_client_identity.py +++ b/posthog/mcp/_client_identity.py @@ -36,14 +36,14 @@ def apply_meta_client_info( fields the request actually carries override, so a legacy request (no ``_meta``) leaves all three untouched.""" try: - # A RequestContext carries the meta directly; a request nests it under - # `params`. Either way it's a `RequestParams.Meta`, which is - # `extra="allow"` — these keys aren't declared fields, so they land in - # `model_extra`. - meta = getattr(source, "meta", None) or getattr( - getattr(source, "params", None), "meta", None - ) + # `_meta` reaches us as a `RequestParams.Meta` model, not a dict: a + # RequestContext carries it directly, a request nests it under `params`. + # The model is `extra="allow"` and these keys aren't declared fields, so + # they land in `model_extra` — the dict we actually want. + params = getattr(source, "params", None) + meta = getattr(source, "meta", None) or getattr(params, "meta", None) meta = getattr(meta, "model_extra", None) or {} + client_info = meta.get(META_CLIENT_INFO_KEY) or {} return ( _str(client_info.get("name")) or client_name, From 362c995b3750f352c2e26083d278277f85022010 Mon Sep 17 00:00:00 2001 From: Georgis Andonis Date: Thu, 30 Jul 2026 17:26:15 +0300 Subject: [PATCH 5/7] fix(mcp): read _meta on mcp 2.x as well as 1.x MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verified the two key strings against the 2026-07-28 schema and against mcp 2.0.0, which exports them as CLIENT_INFO_META_KEY and PROTOCOL_VERSION_META_KEY; they match exactly. But mcp 2.0.0 (released 2026-07-28, stable) drops RequestParams.Meta and hands `_meta` over as a plain dict, so `model_extra` doesn't exist there and the reader returned nothing on the very generation that speaks the revision it was written for. Take the dict when we're given one and only unwrap `model_extra` on 1.x. Also corrects the module docstring, which described v2 as "still beta" — it is the stable line, and `pip install mcp` now resolves to it. Generated-By: PostHog Code Task-Id: 14a95eda-2573-4c9a-9191-d5eecb8e8d44 --- posthog/mcp/_client_identity.py | 24 +++++++++++++++--------- posthog/test/mcp/test_client_identity.py | 17 +++++++++++++++++ 2 files changed, 32 insertions(+), 9 deletions(-) diff --git a/posthog/mcp/_client_identity.py b/posthog/mcp/_client_identity.py index 8288a203..dcca637e 100644 --- a/posthog/mcp/_client_identity.py +++ b/posthog/mcp/_client_identity.py @@ -3,14 +3,17 @@ That revision removes the ``initialize`` handshake and the ``Mcp-Session-Id`` header (SEP-2575 / SEP-2567), so the client name/version and the protocol version travel in every request's ``params._meta`` under the reverse-DNS keys -below instead of arriving once at ``initialize``. We mirror the literal key -strings rather than depend on the (still beta) v2 SDK, which is not a -dependency of this package. +below instead of arriving once at ``initialize``. + +We spell the keys out rather than import them: ``mcp>=2`` exports them as +``CLIENT_INFO_META_KEY`` / ``PROTOCOL_VERSION_META_KEY``, but ``mcp`` is an +optional peer dependency here (``PostHogMCP`` works without it) and 1.x has no +such constants. The strings match the 2026-07-28 schema either way. """ from __future__ import annotations -from typing import Any, Optional, Tuple +from typing import Any, Mapping, Optional, Tuple META_CLIENT_INFO_KEY = "io.modelcontextprotocol/clientInfo" META_PROTOCOL_VERSION_KEY = "io.modelcontextprotocol/protocolVersion" @@ -36,13 +39,16 @@ def apply_meta_client_info( fields the request actually carries override, so a legacy request (no ``_meta``) leaves all three untouched.""" try: - # `_meta` reaches us as a `RequestParams.Meta` model, not a dict: a - # RequestContext carries it directly, a request nests it under `params`. - # The model is `extra="allow"` and these keys aren't declared fields, so - # they land in `model_extra` — the dict we actually want. + # A RequestContext carries `_meta` directly; a request nests it under + # `params`. params = getattr(source, "params", None) meta = getattr(source, "meta", None) or getattr(params, "meta", None) - meta = getattr(meta, "model_extra", None) or {} + # `mcp>=2` hands it over as a plain dict. On 1.x it's a + # `RequestParams.Meta` model instead, and since that model is + # `extra="allow"`, these undeclared reverse-DNS keys land in + # `model_extra` — the dict we actually want. + if not isinstance(meta, Mapping): + meta = getattr(meta, "model_extra", None) or {} client_info = meta.get(META_CLIENT_INFO_KEY) or {} return ( diff --git a/posthog/test/mcp/test_client_identity.py b/posthog/test/mcp/test_client_identity.py index f9d7b541..83b1cd12 100644 --- a/posthog/test/mcp/test_client_identity.py +++ b/posthog/test/mcp/test_client_identity.py @@ -122,6 +122,23 @@ def test_never_raises_on_an_unusable_source(): ) +def test_reads_a_plain_dict_meta(): + """`mcp>=2` drops `RequestParams.Meta` and hands `_meta` over as a plain + dict, so there is no `model_extra` to unwrap on that generation.""" + + class V2Params: + meta = FULL_META + + class V2Request: + params = V2Params() + + assert apply_meta_client_info(V2Request(), None, None, None) == ( + "codex", + "1.2.3", + "2026-07-28", + ) + + def test_falls_back_on_a_malformed_client_info(): """`_meta` is arbitrary client-controlled JSON, so `clientInfo` need not be an object; the wrong shape must fall back rather than raise.""" From ff2f8b501ddaed3ae8d3d9716e2fe282400bad88 Mon Sep 17 00:00:00 2001 From: Georgis Andonis Date: Thu, 30 Jul 2026 17:30:10 +0300 Subject: [PATCH 6/7] refactor(mcp): normalize _meta with dict() instead of branching `dict()` handles both SDK generations in one expression: mcp>=2 hands `_meta` over as a plain dict, and on 1.x pydantic model iteration yields the undeclared reverse-DNS keys just the same. Drops the isinstance branch and the `model_extra` unwrap; verified against both mcp 1.29.0 and 2.0.0. Generated-By: PostHog Code Task-Id: 14a95eda-2573-4c9a-9191-d5eecb8e8d44 --- posthog/mcp/_client_identity.py | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/posthog/mcp/_client_identity.py b/posthog/mcp/_client_identity.py index dcca637e..eaa6f312 100644 --- a/posthog/mcp/_client_identity.py +++ b/posthog/mcp/_client_identity.py @@ -13,7 +13,7 @@ from __future__ import annotations -from typing import Any, Mapping, Optional, Tuple +from typing import Any, Optional, Tuple META_CLIENT_INFO_KEY = "io.modelcontextprotocol/clientInfo" META_PROTOCOL_VERSION_KEY = "io.modelcontextprotocol/protocolVersion" @@ -40,15 +40,13 @@ def apply_meta_client_info( ``_meta``) leaves all three untouched.""" try: # A RequestContext carries `_meta` directly; a request nests it under - # `params`. + # `params`. `dict()` normalizes both SDK generations: `mcp>=2` hands over + # a plain dict, 1.x a `RequestParams.Meta` model whose undeclared + # reverse-DNS keys iterate out just the same. params = getattr(source, "params", None) - meta = getattr(source, "meta", None) or getattr(params, "meta", None) - # `mcp>=2` hands it over as a plain dict. On 1.x it's a - # `RequestParams.Meta` model instead, and since that model is - # `extra="allow"`, these undeclared reverse-DNS keys land in - # `model_extra` — the dict we actually want. - if not isinstance(meta, Mapping): - meta = getattr(meta, "model_extra", None) or {} + meta = dict( + getattr(source, "meta", None) or getattr(params, "meta", None) or {} + ) client_info = meta.get(META_CLIENT_INFO_KEY) or {} return ( From 81fce5b359c90cd76abad72abd339b1c0b0bd174 Mon Sep 17 00:00:00 2001 From: Georgis Andonis Date: Thu, 30 Jul 2026 17:58:35 +0300 Subject: [PATCH 7/7] feat(mcp): capture client identity on mcp 2.x Tested against mcp 2.0.0, the release that speaks the 2026-07-28 revision. Three things were wrong there: - `posthog.mcp` failed to import at all: `_compatibility` did a hard `from mcp.server.fastmcp import FastMCP`, and that module is renamed `mcp.server.mcpserver` in 2.x. Import both tolerantly and accept either. - Client identity read `client_params.clientInfo` / `.protocolVersion`; 2.x spells these `client_info` and puts the negotiated version on the request context. Read the new names, falling back to the old. - `is_tool_result_error` only checked `isError`, spelled `is_error` in 2.x, so every tool error on 2.x was recorded as a success. The tool-call seam itself (`_tool_manager.call_tool`) is unchanged between majors, so wrapping still works. End to end on 2.0.0 we now capture $mcp_initialize, $mcp_tool_call and $exception, each carrying $mcp_client_name, $mcp_client_version and $mcp_protocol_version. Note the SDK reads `_meta` itself on 2.x and hands back a synthesized `client_params`, so `_client_identity` only does work on 1.x, which ignores `_meta` entirely. Both paths are covered. tools/list is still not captured on 2.x: that release replaces the `request_handlers` dispatch the listing seam hooks. The version guard now says so instead of warning the whole major is untested. Generated-By: PostHog Code Task-Id: 14a95eda-2573-4c9a-9191-d5eecb8e8d44 --- .sampo/changesets/mcp-meta-client-identity.md | 2 +- posthog/mcp/__init__.py | 17 +++- posthog/mcp/_compatibility.py | 19 ++++- posthog/mcp/_exceptions.py | 11 +-- posthog/mcp/_instrument_fastmcp.py | 21 +++-- posthog/mcp/_instrumentation.py | 10 ++- posthog/test/mcp/test_mcp_v2.py | 81 +++++++++++++++++++ 7 files changed, 140 insertions(+), 21 deletions(-) create mode 100644 posthog/test/mcp/test_mcp_v2.py diff --git a/.sampo/changesets/mcp-meta-client-identity.md b/.sampo/changesets/mcp-meta-client-identity.md index 1df374f3..eaae34a3 100644 --- a/.sampo/changesets/mcp-meta-client-identity.md +++ b/.sampo/changesets/mcp-meta-client-identity.md @@ -2,4 +2,4 @@ pypi/posthog: minor --- -feat(mcp): read the MCP client name/version and protocol version from each request's `_meta` (`io.modelcontextprotocol/clientInfo` and `io.modelcontextprotocol/protocolVersion`), so `$mcp_client_name`, `$mcp_client_version`, and `$mcp_protocol_version` keep populating under the MCP 2026-07-28 stateless revision, which removes the `initialize` handshake (parity with the TypeScript SDK). Existing clients are unaffected — when `_meta` is absent, the values from the session token / `initialize` still apply. +feat(mcp): capture client identity under the MCP 2026-07-28 revision, which removes the `initialize` handshake and carries the client name/version and protocol version in every request's `_meta` instead. `$mcp_client_name`, `$mcp_client_version`, and `$mcp_protocol_version` keep populating on both `mcp` 1.x (where the SDK ignores `_meta`, so we read it ourselves) and `mcp>=2` (where the SDK renamed the seams we read identity from). Legacy clients are unaffected. Also fixes tool-error detection on `mcp>=2`, where `CallToolResult.isError` is spelled `is_error` — without it every v2 tool error was recorded as a success. On `mcp>=2`, `tools/list` is not yet captured: that release replaces the `request_handlers` dispatch the listing seam hooks. diff --git a/posthog/mcp/__init__.py b/posthog/mcp/__init__.py index 3da91f7d..38274692 100644 --- a/posthog/mcp/__init__.py +++ b/posthog/mcp/__init__.py @@ -152,9 +152,13 @@ def _resolve_client(posthog_client: Optional[Client]) -> Optional[Client]: def _warn_if_unsupported_mcp_version() -> None: """The adapters hook private MCP SDK seams (``_tool_manager``, ``_mcp_server``, - ``request_handlers``) tested against ``mcp>=1.26,<2``. Since ``mcp`` is a peer + ``request_handlers``) tested against ``mcp>=1.26,<3``. Since ``mcp`` is a peer dependency we don't pin, advise at runtime when the installed version is outside - that range rather than failing hard (older/newer may still mostly work).""" + that range rather than failing hard (older/newer may still mostly work). + + On ``mcp>=2`` tool calls and client identity are captured, but ``tools/list`` + is not: that release replaces the ``request_handlers`` dispatch the listing + seam hooks.""" try: from importlib.metadata import version @@ -162,11 +166,16 @@ def _warn_if_unsupported_mcp_version() -> None: major, minor = (int(p) for p in installed.split(".")[:2]) except Exception: # noqa: BLE001 - never let a version probe break instrument() return - if (major, minor) < (1, 26) or major >= 2: + if (major, minor) < (1, 26) or major >= 3: log( - f"Warning: PostHog MCP analytics is tested against mcp>=1.26,<2; found {installed}. " + f"Warning: PostHog MCP analytics is tested against mcp>=1.26,<3; found {installed}. " "Instrumentation hooks private SDK internals and may behave unexpectedly." ) + elif major >= 2: + log( + f"Note: on mcp {installed}, tool calls are captured but tools/list is not " + "(that release replaces the request_handlers dispatch)." + ) def _canonical_server(server: Any) -> Any: diff --git a/posthog/mcp/_compatibility.py b/posthog/mcp/_compatibility.py index 37b017df..a0140c85 100644 --- a/posthog/mcp/_compatibility.py +++ b/posthog/mcp/_compatibility.py @@ -8,13 +8,26 @@ from typing import Any -from mcp.server.fastmcp import FastMCP from mcp.server.lowlevel import Server as LowLevelServer +# The official SDK's high-level server, under both names it has shipped under: +# `mcp.server.fastmcp.FastMCP` on 1.x, renamed `mcp.server.mcpserver.MCPServer` +# in mcp>=2. Only one of these resolves on any given install, so import both +# tolerantly — a hard import of either breaks `posthog.mcp` on the other major. +_HIGH_LEVEL_SERVERS: tuple = () +for _module, _name in ( + ("mcp.server.fastmcp", "FastMCP"), + ("mcp.server.mcpserver", "MCPServer"), +): + try: + _HIGH_LEVEL_SERVERS += (getattr(__import__(_module, fromlist=[_name]), _name),) + except Exception: # noqa: BLE001 - absent on the other major version + pass + def is_fastmcp(server: Any) -> bool: - """The official SDK's high-level server (``mcp.server.fastmcp.FastMCP``).""" - return isinstance(server, FastMCP) + """The official SDK's high-level server, on either major version.""" + return bool(_HIGH_LEVEL_SERVERS) and isinstance(server, _HIGH_LEVEL_SERVERS) def is_fastmcp_v2(server: Any) -> bool: diff --git a/posthog/mcp/_exceptions.py b/posthog/mcp/_exceptions.py index bf6e6f42..d2888ea9 100644 --- a/posthog/mcp/_exceptions.py +++ b/posthog/mcp/_exceptions.py @@ -53,12 +53,13 @@ def _from_message(message: str) -> ErrorProperties: def _is_call_tool_result(value: Any) -> bool: """Detect a CallToolResult error (``{isError, content: [...]}``), whether a - dict or a pydantic model from the ``mcp`` SDK.""" + dict or a pydantic model from the ``mcp`` SDK. ``isError`` is spelled + ``is_error`` on mcp>=2.""" if isinstance(value, dict): - return "isError" in value and isinstance(value.get("content"), list) - return hasattr(value, "isError") and isinstance( - getattr(value, "content", None), list - ) + has_flag = "isError" in value or "is_error" in value + return has_flag and isinstance(value.get("content"), list) + has_flag = hasattr(value, "isError") or hasattr(value, "is_error") + return has_flag and isinstance(getattr(value, "content", None), list) def _extract_call_tool_result_message(result: Any) -> str: diff --git a/posthog/mcp/_instrument_fastmcp.py b/posthog/mcp/_instrument_fastmcp.py index 672aec5c..dd165ff0 100644 --- a/posthog/mcp/_instrument_fastmcp.py +++ b/posthog/mcp/_instrument_fastmcp.py @@ -433,8 +433,13 @@ def _context_request_context(context: Any) -> Any: def _client_info(context: Any) -> Tuple[Optional[str], Optional[str]]: try: client_params = context.request_context.session.client_params - if client_params and client_params.clientInfo: - return client_params.clientInfo.name, client_params.clientInfo.version + # `clientInfo` on mcp 1.x; renamed `client_info` in mcp>=2, where the SDK + # synthesizes it from each request's `_meta` for us. + info = getattr(client_params, "clientInfo", None) or getattr( + client_params, "client_info", None + ) + if info: + return info.name, info.version except Exception: # noqa: BLE001 pass return None, None @@ -442,9 +447,15 @@ def _client_info(context: Any) -> Tuple[Optional[str], Optional[str]]: def _protocol_version(context: Any) -> Optional[str]: try: - client_params = context.request_context.session.client_params - if client_params: - return client_params.protocolVersion + # mcp>=2 puts the negotiated version straight on the request context. + ctx = context.request_context + version = getattr(ctx, "protocol_version", None) + if version: + return version + client_params = ctx.session.client_params + return getattr(client_params, "protocolVersion", None) or getattr( + client_params, "protocol_version", None + ) except Exception: # noqa: BLE001 pass return None diff --git a/posthog/mcp/_instrumentation.py b/posthog/mcp/_instrumentation.py index 2223388a..e433128e 100644 --- a/posthog/mcp/_instrumentation.py +++ b/posthog/mcp/_instrumentation.py @@ -109,10 +109,14 @@ def drain_pending_sync(timeout: Optional[float] = None) -> None: def is_tool_result_error(result: Any) -> bool: - """MCP tool results signal errors via ``isError: true`` rather than raising.""" + """MCP tool results signal errors via ``isError: true`` rather than raising + (spelled ``is_error`` on mcp>=2).""" if isinstance(result, dict): - return result.get("isError") is True - return getattr(result, "isError", None) is True + return result.get("isError") is True or result.get("is_error") is True + return ( + getattr(result, "isError", None) is True + or getattr(result, "is_error", None) is True + ) def build_tool_call_request( diff --git a/posthog/test/mcp/test_mcp_v2.py b/posthog/test/mcp/test_mcp_v2.py new file mode 100644 index 00000000..f11c5ad3 --- /dev/null +++ b/posthog/test/mcp/test_mcp_v2.py @@ -0,0 +1,81 @@ +"""Client identity on mcp>=2, which renamed the SDK seams this SDK hooks. + +Skipped on mcp 1.x, where `mcp.server.mcpserver` doesn't exist. Run with an +`mcp>=2` install to exercise it. +""" + +import pytest + +pytest.importorskip("mcp.server.mcpserver") + +import mcp # noqa: E402 +import mcp.types as mcp_types # noqa: E402 +from mcp.server.mcpserver import MCPServer # noqa: E402 + +from posthog.mcp import instrument # noqa: E402 +from posthog.test.mcp._helpers import ( # noqa: E402 + FakeClient, + events_named as _events, + flush_background as _flush, +) + +CLIENT = mcp_types.Implementation(name="codex", version="1.2.3") + + +def make_server(): + server = MCPServer("test-server") + + @server.tool() + def add(a: int, b: int) -> int: + return a + b + + @server.tool() + def boom() -> str: + raise ValueError("explode") + + return server + + +async def test_captures_client_identity_on_mcp_v2(): + """mcp>=2 renamed `client_params.clientInfo` to `client_info` and puts the + negotiated version on the request context, so identity must come from there.""" + server = make_server() + client = FakeClient() + instrument(server, client) + + async with mcp.Client(server, client_info=CLIENT) as c: + result = await c.call_tool("add", {"a": 1, "b": 2}) + assert not result.is_error + await _flush() + + calls = _events(client, "$mcp_tool_call") + assert calls + props = calls[0]["properties"] + assert props["$mcp_tool_name"] == "add" + assert props["$mcp_client_name"] == "codex" + assert props["$mcp_client_version"] == "1.2.3" + assert props["$mcp_protocol_version"] == "2026-07-28" + assert props["$mcp_is_error"] is False + + init = _events(client, "$mcp_initialize") + assert init and init[0]["properties"]["$mcp_client_name"] == "codex" + + +async def test_flags_tool_errors_on_mcp_v2(): + """`CallToolResult.isError` is spelled `is_error` on mcp>=2; without handling + both, every v2 tool error is recorded as a success.""" + server = make_server() + client = FakeClient() + instrument(server, client) + + async with mcp.Client(server, client_info=CLIENT) as c: + await c.call_tool("boom", {}) + await _flush() + + call = next( + e + for e in _events(client, "$mcp_tool_call") + if e["properties"]["$mcp_tool_name"] == "boom" + ) + assert call["properties"]["$mcp_is_error"] is True + assert _events(client, "$exception")