feat(mcp): read client identity from request _meta - #803
Draft
gesh wants to merge 7 commits into
Draft
Conversation
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
Contributor
posthog-python Compliance ReportDate: 2026-07-30 15:06:38 UTC ✅ All Tests Passed!111/111 tests passed Capture_V1 Tests✅ 94/94 tests passed View Details
Feature_Flags Tests✅ 17/17 tests passed View Details
|
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
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
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
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
`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
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The MCP 2026-07-28 spec revision removes the
initializehandshake and theMcp-Session-Idheader (SEP-2575 / SEP-2567). Client identity now travels in every request'sparams._meta, underio.modelcontextprotocol/clientInfoandio.modelcontextprotocol/protocolVersion.We only read client info from
initialize(or from the session token minted there), so under the new spec both sources vanish and$mcp_client_name,$mcp_client_version, and$mcp_protocol_versionwould silently go null.This adds support for those
_metafields: a new_client_identitymodule reads them off whatever each call site has (a FastMCPContext, a low-levelRequestContext, a request object, or a plain dict), layered into the sharedresolve_session_and_client()so all four adapter call sites pick it up with a one-line change each._metatakes precedence over the transport values and the session token, since it's the per-request truth under the new spec. When it's absent nothing changes — legacy clients behave exactly as before.Parity with the TypeScript SDK, which shipped this in
@posthog/mcp0.10.1 (PostHog/posthog-js#4237).Testing
18 new tests: the reader, precedence against a real encoded session token, and end-to-end passes through both adapters. Full MCP suite green (159).
Note
check_public_api.pyfails on this branch, but it fails identically on a cleanmain— pre-existingposthog.ai.prompts.configsnapshot drift from #801, unrelated to this PR.Created with PostHog Code