Skip to content

feat(mcp): support the MCP 2026-07-28 spec and mcp 2.x SDK - #830

Draft
lucasheriques wants to merge 4 commits into
mainfrom
posthog-code/mcp-spec-2026-python
Draft

feat(mcp): support the MCP 2026-07-28 spec and mcp 2.x SDK#830
lucasheriques wants to merge 4 commits into
mainfrom
posthog-code/mcp-spec-2026-python

Conversation

@lucasheriques

Copy link
Copy Markdown
Contributor

Problem

The MCP 2026-07-28 spec revision (SEP-2575/SEP-2567) removes the initialize handshake and the Mcp-Session-Id header, and its official Python SDK ships as mcp 2.0.0 — which pip install mcp already 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 scaffoldinginstalled_mcp_generation() probe, requires_mcp_v1/requires_mcp_v2 markers so one suite runs in both envs, a tests-mcp-v2 CI job (mcp 1.x and 2.x cannot coexist in one venv — same package name), scripts/validate-mcp-matrix.sh for a local PASS/FAIL matrix, and the test extra pinned to mcp>=1.28.1,<2 so 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 ServerMiddleware seam (no private-attr patching). Captures tools/call, tools/list, and server/discover (which feeds the existing lazy $mcp_initialize synthesis). Client identity and protocol version come from the per-request _meta envelope (io.modelcontextprotocol/clientInfo / .../protocolVersion, verified against the authoritative 2026-07-28 schema), with initialize-params fallback for legacy-era clients on the v2 SDK. An MRTR input_required interim result is never an error and stamps the new $mcp_result_type property. 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 stable ses_ 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 of handle_identify so the customer's identify callback runs once per request and its distinct_id feeds the session key. Precedence: token > mcp > sticky-mcp > derived > generated — never derived without a distinct_id (an anonymous key would merge unrelated users). Every event now carries $mcp_session_id_source so downstream can segment and later query-time stitching can refine.

4. Review fixes — dict-shaped v2 error results captured unwrapped (the interim _ForceErrorFlag broke $mcp_response serialization), and ctx.session_id threaded 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)

  • Full MRTR round-trip stitching (only $mcp_result_type labels interim results; the v2 wire's requestState is the future stitching key).
  • v2 context-parameter injection ($mcp_intent via injected context), get_more_tools, and tool-list mutation — the v2 adapter never mutates responses (also keeps SEP-2549 ttlMs/cacheScope intact).
  • Enriching the v2 identify callback's extra with request headers (needs a sanitization design first — headers carry bearer tokens and extra currently flows into $identify event parameters).
  • Draft feat(mcp): read client identity from request _meta #803 (client identity from params._meta in 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 real MCPServer over the SDK's in-memory transport through server/discover, tools/list, tools/call, MRTR, and legacy-initialize paths.
  • ruff format --check / ruff check (repo-pinned 0.11.12) clean; mypy | mypy-baseline filter clean; full-suite --collect-only shows no import breakage (1997 tests).
  • Wire-shape assumptions (envelope keys, ResultType) verified against the authoritative spec schema (schema/2026-07-28/schema.ts) and the published mcp==2.0.0 package.

Note for reviewers: the cross-model codex review pre-ready gate is pending (quota resets Aug 8); this PR stays draft until that runs clean.


Created with PostHog Code

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
@github-actions

github-actions Bot commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

posthog-python Compliance Report

Date: 2026-08-04 23:23:35 UTC
Duration: 338868ms

✅ All Tests Passed!

111/111 tests passed


Capture_V1 Tests

94/94 tests passed

View Details
Test Status Duration
Endpoint And Method.Targets V1 Endpoint 516ms
Endpoint And Method.Does Not Use Legacy Endpoints 1009ms
Required Headers.Has Authorization Bearer Header 1009ms
Required Headers.Has Content Type Json 1009ms
Required Headers.Has Posthog Sdk Info Format 1008ms
Required Headers.Has Posthog Attempt Header 1009ms
Required Headers.Has Posthog Request Id 1008ms
Required Headers.Has Posthog Request Timestamp 1009ms
Required Headers.Has User Agent 1009ms
Body Format.Body Has Created At And Batch 1008ms
Body Format.No Api Key In Body 1009ms
Body Format.No Sent At In Body 1009ms
Event Format.Event Has Required Root Fields 1009ms
Event Format.Event Uuid Is Valid 1009ms
Event Format.Event Timestamp Is Rfc3339 1010ms
Event Format.Distinct Id Is String 1009ms
Event Format.Distinct Id At Root Not Properties 1009ms
Event Format.Custom Properties Preserved 1010ms
Event Format.Set Properties Preserved 1010ms
Event Format.Set Once Properties Preserved 1009ms
Event Format.Groups Properties Preserved 1011ms
Event Format.Sdk Generates Uuid If Not Provided 1011ms
Event Format.Event Has Required Root Fields Batch 1013ms
Event Format.Event Uuid Is Valid Batch 1013ms
Event Format.Event Timestamp Is Rfc3339 Batch 1012ms
Event Format.Distinct Id Is String Batch 1012ms
Event Format.Distinct Id At Root Not Properties Batch 1013ms
Event Format.Custom Properties Preserved Batch 1013ms
Event Format.Set Properties Preserved Batch 1011ms
Event Format.Set Once Properties Preserved Batch 1012ms
Event Format.Groups Properties Preserved Batch 1013ms
Event Format.Sdk Generates Uuid If Not Provided Batch 1013ms
Batch Behavior.Multiple Events In Single Batch 1506ms
Batch Behavior.Batch Envelope Smoke 1014ms
Batch Behavior.Flush With No Events Sends Nothing 1005ms
Batch Behavior.Flush At Triggers Batch 1508ms
Batch Behavior.Created At Reflects Batch Creation Time 1012ms
Deduplication.Generates Unique Uuids 1507ms
Deduplication.Different Events Same Content Different Uuids 1507ms
Deduplication.Preserves Uuid On Retry 7514ms
Deduplication.Preserves Timestamp On Retry 7513ms
Deduplication.Preserves Uuid And Timestamp On Batch Retry 7513ms
Deduplication.No Duplicate Events In Batch 1504ms
Header Behavior On Retry.Attempt Header Starts At One 1008ms
Header Behavior On Retry.Attempt Header Increments On Retry 14524ms
Header Behavior On Retry.Request Id Preserved On Retry 7508ms
Header Behavior On Retry.Different Requests Have Different Request Ids 3515ms
Header Behavior On Retry.Request Timestamp Changes On Retry 7509ms
Response Format Validation.Success Response Has Uuid Keyed Results 1010ms
Response Format Validation.Success Response Has Ok For Each Event 1507ms
Response Format Validation.Success No Retry After When All Ok 1507ms
Response Format Validation.Success Retry After Present When Retry Events 2510ms
Response Format Validation.Success No Retry After When Drop Only 1507ms
Response Format Validation.Response Echoes Request Id 1009ms
Retry Behavior.Retries On 408 7513ms
Retry Behavior.Retries On 500 7514ms
Retry Behavior.Retries On 503 9515ms
Retry Behavior.Retries On 504 7511ms
Retry Behavior.Retryable Errors Have Retry After 4508ms
Retry Behavior.Respects Retry After On Retryable Error 12518ms
Retry Behavior.Does Not Retry On 400 3503ms
Retry Behavior.Does Not Retry On 401 3509ms
Retry Behavior.Does Not Retry On 402 3507ms
Retry Behavior.Does Not Retry On 413 3508ms
Retry Behavior.Does Not Retry On 415 3508ms
Retry Behavior.Non Retryable Errors Have No Retry After 3508ms
Retry Behavior.Implements Backoff 23520ms
Retry Behavior.Max Retries Respected 23533ms
Partial Batch Handling.Handles 200 Full Success 3002ms
Partial Batch Handling.Handles 200 With All Ok 4506ms
Partial Batch Handling.Does Not Retry Dropped Events 4510ms
Partial Batch Handling.Does Not Retry Limited Events 4509ms
Partial Batch Handling.Prunes Ok Events On Partial Retry 7513ms
Partial Batch Handling.Prunes Dropped Events On Partial Retry 7512ms
Partial Batch Handling.Retries Only Retry Events From Partial 7508ms
Partial Batch Handling.Partial Retry Preserves Uuids 7515ms
Partial Batch Handling.Partial Retry Attempt Header Increments 7513ms
Partial Batch Handling.Partial Retry Request Id Preserved 7512ms
Partial Batch Handling.Respects Retry After On Partial 9512ms
Partial Batch Handling.Unknown Result Treated As Terminal 4507ms
Partial Batch Handling.Mixed Ok Drop Limited No Retry 4508ms
Compression.Sends Gzip Content Encoding 1009ms
Compression.No Content Encoding When Disabled 1008ms
Compression.Compressed Body Is Decompressible 1009ms
Error Handling.Does Not Retry On Unknown 4Xx 3508ms
Event Options.Cookieless Mode Override 1009ms
Event Options.Disable Skew Correction Override 1009ms
Event Options.Process Person Profile Override 1009ms
Event Options.Product Tour Id Override 1009ms
Event Options.Unset Options Omitted 1008ms
Event Options.Options Override In Batch 1012ms
Geoip And Historical Migration.Geoip Disable Injected Into Properties 1009ms
Geoip And Historical Migration.Historical Migration Set In Body 1010ms
Geoip And Historical Migration.Historical Migration Absent By Default 1008ms

Feature_Flags Tests

17/17 tests passed

View Details
Test Status Duration
Request Payload.Request With Person Properties Device Id 1006ms
Request Payload.Flags Request Uses V2 Query Param 1006ms
Request Payload.Flags Request Hits Flags Path Not Decide 1007ms
Request Payload.Flags Request Omits Authorization Header 1006ms
Request Payload.Token In Flags Body Matches Init 1007ms
Request Payload.Groups Round Trip 1007ms
Request Payload.Groups Default To Empty Object 1006ms
Request Payload.Disable Geoip False Propagates As Geoip Disable False 1007ms
Request Payload.Disable Geoip Omitted Defaults To False 1006ms
Request Payload.Flag Keys To Evaluate Contains Only Requested Key 1007ms
Request Lifecycle.No Flags Request On Init Alone 503ms
Request Lifecycle.No Flags Request On Normal Capture 1507ms
Request Lifecycle.Two Flag Calls Produce Two Remote Requests 1011ms
Request Lifecycle.Mock Response Value Is Returned To Caller 1002ms
Retry Behavior.Retries Flags On 502 1007ms
Retry Behavior.Retries Flags On 504 1006ms
Side Effect Events.Get Feature Flag Captures Feature Flag Called Event 1509ms

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant