feat(mcp): support the MCP 2026-07-28 spec and mcp 2.x SDK - #830
Draft
lucasheriques wants to merge 4 commits into
Draft
feat(mcp): support the MCP 2026-07-28 spec and mcp 2.x SDK#830lucasheriques wants to merge 4 commits into
lucasheriques wants to merge 4 commits into
Conversation
The 2026-07-28 spec ships as `mcp` 2.x, a breaking rewrite of the same PyPI package that can't coexist with 1.x in one venv. Make the posthog.mcp suite valid on both SDKs so v2 support can be built and validated incrementally. What: - `posthog/mcp/_mcp_version.py`: `installed_mcp_generation() -> 1|2|None` probe (importlib.metadata; never raises), used by both runtime and tests. - `posthog/test/mcp/_helpers.py`: `requires_mcp_v1` / `requires_mcp_v2` skipif markers keyed off the probe. - Marked every v1-internals test (`request_handlers` shape, fastmcp import, stateless-token flows) with the v1 marker, preferring module-level `pytestmark` and guarding crash-prone module imports so v2 collection is clean. jlowin `fastmcp`'s server layer raises a rewritten ImportError under mcp 2.x, so its module guards the `from fastmcp import FastMCP` by hand (importorskip mis-handles that rewrite). - `posthog/test/mcp/test_mcp_version.py`: generation-probe tests that run in both envs and anchor the marker mutual-exclusivity invariant. - Pinned the `test` extra to `mcp>=1.28.1,<2` (lock refreshed to match) so a no-upper-bound resolve can't silently flip CI to 2.0. - CI `tests-mcp-v2` job (3.12): sync test extra, install `mcp>=2,<3` over it, run the mcp subset. `scripts/validate-mcp-matrix.sh` does the same locally across two throwaway venvs and prints a PASS/FAIL matrix. How tested: - v1 env (mcp 1.29): `pytest posthog/test/mcp` -> 151 passed, 1 skipped. - v2 env (mcp 2.0): `pytest posthog/test/mcp` -> 111 passed, 11 skipped, no collection errors; v2 probe tests pass, v1-only tests skip cleanly. - `ruff@0.11.12 check .` / `format --check .` clean; `mypy ... | mypy-baseline filter` -> no issues (190 files). Generated-By: PostHog Code Task-Id: c17ecd78-bb05-4b3c-b621-458abba9c6aa
The v1 adapters import `mcp.server.fastmcp` at module level, so on mcp 2.x
`instrument()` raised a bare ModuleNotFoundError before it could dispatch. Make
compat detection lazy/per-generation and add a capture-only adapter that hooks
the official 2.x `ServerMiddleware` seam.
What:
- `_compatibility.py`: dropped module-level v1 imports; every predicate imports
lazily and returns False on ImportError. `is_fastmcp` no longer crashes on
2.x; added `is_mcpserver_v2` (mcp.server.mcpserver.MCPServer) and made
`is_low_level_server` generation-agnostic.
- `_instrument_v2.py`: attaches one `(ctx, call_next)` middleware to
`server.middleware` (the same public list `MCPServer` and low-level `Server`
expose — no private-attr patching). Captures `tools/call` (reusing
`record_tool_call`), `tools/list` (reusing `record_tools_list`, read-only —
no response mutation), and `server/discover` (reusing the lazy
`_maybe_emit_initialize`). Client name/version + protocol come from the
per-request `_meta` envelope (`io.modelcontextprotocol/clientInfo`,
`.../protocolVersion`) on 2026-07-28 sessions, or from `initialize` params on
a legacy-negotiated session. Identify flows via `prepare_request`.
- MRTR: an `input_required` result is NOT an error; it stamps the new
`$mcp_result_type` property (added to `constants.py`, threaded through
`_capture.py`/`_posthog_events.py`/`record_tool_call`). Full round-trip
stitching is out of scope. The middleware sees results as wire dicts
(`{"isError": ...}` / `{"resultType": "input_required", ...}`), so error
detection handles both dict and model shapes.
- `__init__.py`: `instrument()` dispatches on `installed_mcp_generation()`;
`_canonical_server` now unwraps `_lowlevel_server` too (v2's wrapper attr).
On an attach failure the no-op fallback logs an actionable message naming the
detected generation and both supported ranges. `_warn_if_unsupported_mcp_
version` updated to advertise mcp>=1.26,<2 and mcp>=2,<3.
- No context injection, no get_more_tools, no stateless minting on v2 (SEP-2567
removed the Mcp-Session-Id header). v1 paths are behavior-preserved.
How tested:
- New `test_instrument_v2.py` (7 tests) drives a real `MCPServer` over the SDK
in-memory transport through modern (server/discover) and legacy (initialize)
handshakes: tool call w/ envelope identity, isError -> $exception, tools/list
names, initialize-once, identify attribution, MRTR result_type-not-error.
- v2 env: `pytest posthog/test/mcp` -> 118 passed, 11 skipped.
- v1 env: 151 passed, 2 skipped (byte-for-byte v1 behavior; only the version-
warning assertion string updated). Full-suite `--collect-only`: 1981 tests,
no import errors.
- `ruff@0.11.12 check .`/`format --check .` clean; `mypy | mypy-baseline
filter` -> no issues (192 files).
Generated-By: PostHog Code
Task-Id: c17ecd78-bb05-4b3c-b621-458abba9c6aa
SEP-2567 removed the Mcp-Session-Id header on 2026-07-28, so stateless / per- request servers have no transport session to correlate a user's tool calls into one $session_id — each request would mint its own. Per SEP-2567's telemetry guidance, derive the session from the authenticated principal + client instead. What: - `_derived_sessions.py`: a module-level (process-shared, so per-request server instances correlate) `DerivedSessionRegistry` mapping `(distinct_id, client_name, client_version)` -> a rolling `ses_` UUIDv7. Thread-safe (lock), LRU-bounded (10k), idle-evicts entries past 2x the inactivity timeout, rolls a session after the timeout. Fork-reset via `os.register_at_fork`, mirroring the background-loop reset. - `session.resolve_session_id`: new precedence token > mcp > sticky-mcp > derived > generated. Derived is taken only when a `distinct_id` is present — deriving anonymously would merge unrelated users under one session. - `_internal.py`: split identity resolution out of `handle_identify` into `resolve_identity(data, request, extra)` (callback invoked at most once, side-effect-free). `prepare_request` now resolves identity FIRST and threads it into both `resolve_session_id` (for the derived key) and `handle_identify` (dedup still keyed by the resolved session id). - Provenance: `$mcp_session_id_source` (token|mcp|derived|generated) added to `constants.py` and stamped on every $mcp_* event (and $identify). Threaded through `record_tool_call`/`record_tools_list`/`record_missing_capability`/ `_maybe_emit_initialize` and the v1 (fastmcp, lowlevel) + v2 adapters; the source is snapshotted at resolution time (shared per-server state) rather than re-read at capture. Additive. - The v2 adapter passes no token / no mcp header by construction, so its sessions are `derived` when identified, `generated` otherwise. How tested: - `test_derived_sessions.py` (parameterized): same key within gap -> one session; gap expiry -> new; different distinct_id/client_name/client_version -> different; no distinct_id -> generated; LRU bound; idle eviction; concurrency (8 threads, one key -> one session); fork reset; full precedence table (token/mcp/derived/generated); derived requires distinct_id. - Provenance parameterized tests assert `$mcp_session_id_source` on initialize/ tools_list/tool_call/identify in both the v1 (test_lowlevel) and v2 (test_instrument_v2) adapters: identified -> derived, anonymous -> generated. - v1 env: 167 passed, 2 skipped. v2 env: 134 passed, 13 skipped. Full-suite `--collect-only`: 1997 tests, no import errors. - `ruff@0.11.12 check .`/`format --check .` clean; `mypy | mypy-baseline filter` -> no issues (194 files). Generated-By: PostHog Code Task-Id: c17ecd78-bb05-4b3c-b621-458abba9c6aa
Two review fixes on the v2 adapter. (1) Drop the _ForceErrorFlag wrapper: for dict-shaped error results (the common v2 wire case) it masked dict-ness, so _to_jsonable returned the raw wrapper into the event's $mcp_response — unserializable in production. is_tool_result_error now reads the 2.x models' snake_case is_error directly and results pass through unwrapped. (2) Thread ctx.session_id into session resolution: legacy-era clients on the v2 SDK still carry a transport session id, which now resolves with "mcp" provenance instead of falling through to derived/generated. Tested: scripts/validate-mcp-matrix.sh — mcp 1.x PASS, mcp 2.x PASS (135 passed / 13 skipped); ruff 0.11.12 format+check clean. New regression tests: JSON-serializability of captured error responses, and stable "mcp" session provenance for a stubbed legacy-era ctx. Generated-By: PostHog Code Task-Id: c17ecd78-bb05-4b3c-b621-458abba9c6aa
Contributor
posthog-python Compliance ReportDate: 2026-08-04 23:23:35 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
|
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.
Problem
The MCP 2026-07-28 spec revision (SEP-2575/SEP-2567) removes the
initializehandshake and theMcp-Session-Idheader, and its official Python SDK ships asmcp2.0.0 — whichpip install mcpalready resolves to today. On mcp 2.0,posthog.mcp.instrument()crashes into a silent no-op (from mcp.server.fastmcp import FastMCP— that module no longer exists), so customers on the current SDK get zero analytics with no signal. And even with capture fixed, stateless traffic has no transport session, so every session-keyed metric breaks.Changes
Three staged commits plus review fixes:
1. Version-matrix test scaffolding —
installed_mcp_generation()probe,requires_mcp_v1/requires_mcp_v2markers so one suite runs in both envs, atests-mcp-v2CI job (mcp 1.x and 2.x cannot coexist in one venv — same package name),scripts/validate-mcp-matrix.shfor a local PASS/FAIL matrix, and the test extra pinned tomcp>=1.28.1,<2so a lock refresh can't silently flip the main suite onto 2.0.2. mcp 2.x adapter — capture-only middleware attached through the v2 SDK's official
ServerMiddlewareseam (no private-attr patching). Capturestools/call,tools/list, andserver/discover(which feeds the existing lazy$mcp_initializesynthesis). Client identity and protocol version come from the per-request_metaenvelope (io.modelcontextprotocol/clientInfo/.../protocolVersion, verified against the authoritative 2026-07-28 schema), withinitialize-params fallback for legacy-era clients on the v2 SDK. An MRTRinput_requiredinterim result is never an error and stamps the new$mcp_result_typeproperty.instrument()fails loud and actionable when it can't attach — never a silent no-op.3. Derived sessions for stateless traffic — SEP-2567's own telemetry guidance is to key on "the authenticated principal … or a request-level correlation ID". New process-shared registry maps
(distinct_id, client_name, client_version)to a stableses_id with the existing 30-min inactivity rollover; module-level so per-request server instances share it; LRU-bounded, idle-evicting, fork-reset. Identity resolution was split out ofhandle_identifyso the customer'sidentifycallback runs once per request and itsdistinct_idfeeds the session key. Precedence: token > mcp > sticky-mcp > derived > generated — never derived without adistinct_id(an anonymous key would merge unrelated users). Every event now carries$mcp_session_id_sourceso downstream can segment and later query-time stitching can refine.4. Review fixes — dict-shaped v2 error results captured unwrapped (the interim
_ForceErrorFlagbroke$mcp_responseserialization), andctx.session_idthreaded through so legacy-era clients on the v2 SDK keep deterministic MCP-session correlation.New property names:
$mcp_result_type,$mcp_session_id_source.Deliberately out of scope (follow-ups)
$mcp_result_typelabels interim results; the v2 wire'srequestStateis the future stitching key).$mcp_intentvia injectedcontext),get_more_tools, and tool-list mutation — the v2 adapter never mutates responses (also keeps SEP-2549ttlMs/cacheScopeintact).identifycallback'sextrawith request headers (needs a sanitization design first — headers carry bearer tokens andextracurrently flows into$identifyevent parameters)._meta#803 (client identity fromparams._metain the v1 adapters) is a complementary bridge for v1 servers receiving modern-shaped traffic — different code path, no conflict.How did you test this code?
scripts/validate-mcp-matrix.sh: mcp 1.x PASS (167 passed / 2 skipped), mcp 2.x PASS (135 passed / 13 skipped) — the v2 leg drives a realMCPServerover the SDK's in-memory transport throughserver/discover,tools/list,tools/call, MRTR, and legacy-initializepaths.ruff format --check/ruff check(repo-pinned 0.11.12) clean;mypy | mypy-baseline filterclean; full-suite--collect-onlyshows no import breakage (1997 tests).ResultType) verified against the authoritative spec schema (schema/2026-07-28/schema.ts) and the publishedmcp==2.0.0package.Created with PostHog Code