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 mcp2.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 officialServerMiddleware 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-actionsBot 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
TestStatusDuration
Endpoint And Method.Targets V1 Endpoint516ms
Endpoint And Method.Does Not Use Legacy Endpoints1009ms
Required Headers.Has Authorization Bearer Header1009ms
Required Headers.Has Content Type Json1009ms
Required Headers.Has Posthog Sdk Info Format1008ms
Required Headers.Has Posthog Attempt Header1009ms
Required Headers.Has Posthog Request Id1008ms
Required Headers.Has Posthog Request Timestamp1009ms
Required Headers.Has User Agent1009ms
Body Format.Body Has Created At And Batch1008ms
Body Format.No Api Key In Body1009ms
Body Format.No Sent At In Body1009ms
Event Format.Event Has Required Root Fields1009ms
Event Format.Event Uuid Is Valid1009ms
Event Format.Event Timestamp Is Rfc33391010ms
Event Format.Distinct Id Is String1009ms
Event Format.Distinct Id At Root Not Properties1009ms
Event Format.Custom Properties Preserved1010ms
Event Format.Set Properties Preserved1010ms
Event Format.Set Once Properties Preserved1009ms
Event Format.Groups Properties Preserved1011ms
Event Format.Sdk Generates Uuid If Not Provided1011ms
Event Format.Event Has Required Root Fields Batch1013ms
Event Format.Event Uuid Is Valid Batch1013ms
Event Format.Event Timestamp Is Rfc3339 Batch1012ms
Event Format.Distinct Id Is String Batch1012ms
Event Format.Distinct Id At Root Not Properties Batch1013ms
Event Format.Custom Properties Preserved Batch1013ms
Event Format.Set Properties Preserved Batch1011ms
Event Format.Set Once Properties Preserved Batch1012ms
Event Format.Groups Properties Preserved Batch1013ms
Event Format.Sdk Generates Uuid If Not Provided Batch1013ms
Batch Behavior.Multiple Events In Single Batch1506ms
Batch Behavior.Batch Envelope Smoke1014ms
Batch Behavior.Flush With No Events Sends Nothing1005ms
Batch Behavior.Flush At Triggers Batch1508ms
Batch Behavior.Created At Reflects Batch Creation Time1012ms
Deduplication.Generates Unique Uuids1507ms
Deduplication.Different Events Same Content Different Uuids1507ms
Deduplication.Preserves Uuid On Retry7514ms
Deduplication.Preserves Timestamp On Retry7513ms
Deduplication.Preserves Uuid And Timestamp On Batch Retry7513ms
Deduplication.No Duplicate Events In Batch1504ms
Header Behavior On Retry.Attempt Header Starts At One1008ms
Header Behavior On Retry.Attempt Header Increments On Retry14524ms
Header Behavior On Retry.Request Id Preserved On Retry7508ms
Header Behavior On Retry.Different Requests Have Different Request Ids3515ms
Header Behavior On Retry.Request Timestamp Changes On Retry7509ms
Response Format Validation.Success Response Has Uuid Keyed Results1010ms
Response Format Validation.Success Response Has Ok For Each Event1507ms
Response Format Validation.Success No Retry After When All Ok1507ms
Response Format Validation.Success Retry After Present When Retry Events2510ms
Response Format Validation.Success No Retry After When Drop Only1507ms
Response Format Validation.Response Echoes Request Id1009ms
Retry Behavior.Retries On 4087513ms
Retry Behavior.Retries On 5007514ms
Retry Behavior.Retries On 5039515ms
Retry Behavior.Retries On 5047511ms
Retry Behavior.Retryable Errors Have Retry After4508ms
Retry Behavior.Respects Retry After On Retryable Error12518ms
Retry Behavior.Does Not Retry On 4003503ms
Retry Behavior.Does Not Retry On 4013509ms
Retry Behavior.Does Not Retry On 4023507ms
Retry Behavior.Does Not Retry On 4133508ms
Retry Behavior.Does Not Retry On 4153508ms
Retry Behavior.Non Retryable Errors Have No Retry After3508ms
Retry Behavior.Implements Backoff23520ms
Retry Behavior.Max Retries Respected23533ms
Partial Batch Handling.Handles 200 Full Success3002ms
Partial Batch Handling.Handles 200 With All Ok4506ms
Partial Batch Handling.Does Not Retry Dropped Events4510ms
Partial Batch Handling.Does Not Retry Limited Events4509ms
Partial Batch Handling.Prunes Ok Events On Partial Retry7513ms
Partial Batch Handling.Prunes Dropped Events On Partial Retry7512ms
Partial Batch Handling.Retries Only Retry Events From Partial7508ms
Partial Batch Handling.Partial Retry Preserves Uuids7515ms
Partial Batch Handling.Partial Retry Attempt Header Increments7513ms
Partial Batch Handling.Partial Retry Request Id Preserved7512ms
Partial Batch Handling.Respects Retry After On Partial9512ms
Partial Batch Handling.Unknown Result Treated As Terminal4507ms
Partial Batch Handling.Mixed Ok Drop Limited No Retry4508ms
Compression.Sends Gzip Content Encoding1009ms
Compression.No Content Encoding When Disabled1008ms
Compression.Compressed Body Is Decompressible1009ms
Error Handling.Does Not Retry On Unknown 4Xx3508ms
Event Options.Cookieless Mode Override1009ms
Event Options.Disable Skew Correction Override1009ms
Event Options.Process Person Profile Override1009ms
Event Options.Product Tour Id Override1009ms
Event Options.Unset Options Omitted1008ms
Event Options.Options Override In Batch1012ms
Geoip And Historical Migration.Geoip Disable Injected Into Properties1009ms
Geoip And Historical Migration.Historical Migration Set In Body1010ms
Geoip And Historical Migration.Historical Migration Absent By Default1008ms

Feature_Flags Tests

17/17 tests passed

View Details
TestStatusDuration
Request Payload.Request With Person Properties Device Id1006ms
Request Payload.Flags Request Uses V2 Query Param1006ms
Request Payload.Flags Request Hits Flags Path Not Decide1007ms
Request Payload.Flags Request Omits Authorization Header1006ms
Request Payload.Token In Flags Body Matches Init1007ms
Request Payload.Groups Round Trip1007ms
Request Payload.Groups Default To Empty Object1006ms
Request Payload.Disable Geoip False Propagates As Geoip Disable False1007ms
Request Payload.Disable Geoip Omitted Defaults To False1006ms
Request Payload.Flag Keys To Evaluate Contains Only Requested Key1007ms
Request Lifecycle.No Flags Request On Init Alone503ms
Request Lifecycle.No Flags Request On Normal Capture1507ms
Request Lifecycle.Two Flag Calls Produce Two Remote Requests1011ms
Request Lifecycle.Mock Response Value Is Returned To Caller1002ms
Retry Behavior.Retries Flags On 5021007ms
Retry Behavior.Retries Flags On 5041006ms
Side Effect Events.Get Feature Flag Captures Feature Flag Called Event1509ms

@github-actions

Copy link
Copy Markdown
Contributor

This PR hasn't seen activity in a week! Should it be merged, closed, or further worked on? If you want to keep it open, post a comment or remove the stale label – otherwise this will be closed in another week.

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

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@lucasheriques