Skip to content

Commit 004c745

Browse files
committed
feat(mcp): support MCP Python SDK v2 and the 2026-07-28 spec revision
instrument() now works on mcp 1.x and 2.x: - New _instrument_v2 adapters: the high-level MCPServer (renamed FastMCP) wraps ToolManager.call_tool — the seam every dispatch routes through — and the low-level Server wraps the string-keyed handler registry through the public add_request_handler/get_request_handler API, late registrations included (the posthog-js#4449 lesson). Strip-vs-leave policy per entry point matches the 1.x pair: the high-level path strips injected context/conversation_id (v2 validates against the function signature), the raw low-level path leaves them optional in the schema. - _compatibility probes are import-tolerant and shape-based (posthog-js ADR-0005): the old module-level `from mcp.server.fastmcp import ...` raised ImportError straight out of instrument() on mcp>=2, crashing the host app. Unsupported servers now degrade to a logged no-op handle. - Conversation-anchored sessions (posthog-js ADR-0004): with enable_conversation_id, $session_id is derived deterministically from the agent-echoed conversation_id — the only correlation that survives 2026-07-28's per-request instances. Byte-for-byte parity with @posthog/mcp (new export derive_session_id_from_conversation), the minted-shape (uuidv7) guard so invented handles can't merge unrelated callers, lowercased echoes, and the prompt-back riding errored results so a first-call failure keeps the conversation together. Applies to the 1.x adapters too — anchoring is not era-gated. - Dual-shape attribute reads (isError/is_error, inputSchema/input_schema, clientInfo/client_info); captured payloads dump by_alias so both majors emit the camelCase wire shape. - Version advisory widened to mcp>=1.26,<3. No new runtime dependencies; mcp stays a lazily-imported peer dependency. jlowin's fastmcp keeps the 1.x seams (it pins mcp<2). Generated-By: PostHog Code Task-Id: ebafcb71-b03b-443d-b40c-d527ed4a04f4
1 parent a52a78a commit 004c745

12 files changed

Lines changed: 839 additions & 63 deletions
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
posthog: minor
3+
---
4+
5+
feat(mcp): support the MCP Python SDK 2.x and the 2026-07-28 spec revision. `instrument()` now wraps `mcp.server.mcpserver.MCPServer` (the renamed FastMCP) and the v2 low-level `Server` (constructor-injected handlers, string-keyed registry, late `add_request_handler` registrations included), capturing tool calls, tools/list, errors, intent, client identity, and `$mcp_protocol_version` on both protocol eras — SDK 1.x paths are unchanged. Conversation-anchored sessions land as the cross-pod correlation for the stateless era (parity with `@posthog/mcp`): with `enable_conversation_id`, `$session_id` is derived deterministically from the agent-echoed `conversation_id` (new export `derive_session_id_from_conversation`), only handles the SDK could have minted (uuidv7) anchor, and the prompt-back now rides errored results too so a first-call failure keeps the conversation together. `instrument()` also no longer crashes on an unsupported or unrecognized MCP SDK — it degrades to a logged no-op.

examples/mcp_analytics_demo.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@
1919
import os
2020

2121
import mcp.types as mcp_types
22+
23+
# MCP SDK 1.x. On mcp>=2 the class moved: `from mcp.server.mcpserver import
24+
# MCPServer` — instrument() works the same on both.
2225
from mcp.server.fastmcp import FastMCP
2326

2427
from posthog import Posthog

posthog/mcp/__init__.py

Lines changed: 69 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,23 @@
44

55
"""PostHog MCP analytics SDK — product analytics for Model Context Protocol servers.
66
7-
Wrap a Python MCP server (``FastMCP`` or low-level ``mcp.server.Server``) so every
8-
tool call, agent intent, and failure is captured to PostHog as a ``$mcp_*`` event::
7+
Wrap a Python MCP server so every tool call, agent intent, and failure is
8+
captured to PostHog as a ``$mcp_*`` event. Works with the MCP Python SDK 1.x
9+
*and* 2.x (the 2026-07-28 spec revision) — the high-level server class moved
10+
between majors, but ``instrument()`` is the same::
911
1012
from posthog import Posthog
1113
from posthog.mcp import instrument
12-
from mcp.server.fastmcp import FastMCP
14+
15+
# MCP SDK 2.x (spec 2026-07-28)
16+
from mcp.server.mcpserver import MCPServer
17+
server = MCPServer("my-server")
18+
19+
# MCP SDK 1.x
20+
# from mcp.server.fastmcp import FastMCP
21+
# server = FastMCP("my-server")
1322
1423
posthog = Posthog("phc_...", host="https://us.i.posthog.com")
15-
server = FastMCP("my-server")
1624
analytics = instrument(server, posthog)
1725
1826
Install is just ``pip install posthog``. ``instrument()`` needs the MCP SDK at runtime,
@@ -43,7 +51,11 @@
4351
)
4452
from .logger import log, set_logger
4553
from .posthog_mcp import PostHogMCP
46-
from .session import derive_session_id_from_mcp_session, new_session_id
54+
from .session import (
55+
derive_session_id_from_conversation,
56+
derive_session_id_from_mcp_session,
57+
new_session_id,
58+
)
4759
from .session_token import (
4860
MCP_SESSION_HEADER,
4961
SessionTokenPayload,
@@ -77,6 +89,10 @@
7789
"PreparedToolCall",
7890
"get_more_tools_result",
7991
"derive_session_id_from_mcp_session",
92+
# Conversation-anchored sessions: the cross-SDK derivation contract with
93+
# posthog-js (the 2026-07-28 revision has no protocol sessions, so the
94+
# agent-echoed conversation_id is the only cross-pod session carrier).
95+
"derive_session_id_from_conversation",
8096
# Self-encoded session tokens for stateless / multi-pod servers. Minted onto
8197
# the `Mcp-Session-Id` response header by PostHogMcpStatelessSessionMiddleware
8298
# and decoded on every request; codec is exported for custom HTTP layers.
@@ -157,30 +173,35 @@ def _resolve_client(posthog_client: Optional[Client]) -> Optional[Client]:
157173

158174

159175
def _warn_if_unsupported_mcp_version() -> None:
160-
"""The adapters hook private MCP SDK seams (``_tool_manager``, ``_mcp_server``,
161-
``request_handlers``) tested against ``mcp>=1.26,<2``. Since ``mcp`` is a peer
162-
dependency we don't pin, advise at runtime when the installed version is outside
163-
that range rather than failing hard (older/newer may still mostly work)."""
176+
"""The adapters hook private MCP SDK seams (``_tool_manager``, ``_mcp_server``
177+
/ ``_lowlevel_server``, the request-handler registries) tested against
178+
``mcp>=1.26,<3`` — both the 1.x line and the 2.x line (spec 2026-07-28).
179+
Since ``mcp`` is a peer dependency we don't pin, advise at runtime when the
180+
installed version is outside that range rather than failing hard (older/newer
181+
may still mostly work)."""
164182
try:
165183
from importlib.metadata import version
166184

167185
installed = version("mcp")
168186
major, minor = (int(p) for p in installed.split(".")[:2])
169187
except Exception: # noqa: BLE001 - never let a version probe break instrument()
170188
return
171-
if (major, minor) < (1, 26) or major >= 2:
189+
if (major, minor) < (1, 26) or major >= 3:
172190
log(
173-
f"Warning: PostHog MCP analytics is tested against mcp>=1.26,<2; found {installed}. "
191+
f"Warning: PostHog MCP analytics is tested against mcp>=1.26,<3; found {installed}. "
174192
"Instrumentation hooks private SDK internals and may behave unexpectedly."
175193
)
176194

177195

178196
def _canonical_server(server: Any) -> Any:
179-
"""The underlying low-level server for high-level wrappers (official FastMCP and
180-
jlowin's fastmcp 2.0 both expose ``_mcp_server``), else the server itself. Used as
181-
the tracking key so instrumenting a wrapper and its underlying server resolve to
182-
one state instead of two divergent ones (matching the TS SDK)."""
183-
low_level = getattr(server, "_mcp_server", None)
197+
"""The underlying low-level server for high-level wrappers (SDK 1.x FastMCP and
198+
jlowin's fastmcp expose ``_mcp_server``; SDK 2.x MCPServer renamed it
199+
``_lowlevel_server``), else the server itself. Used as the tracking key so
200+
instrumenting a wrapper and its underlying server resolve to one state instead
201+
of two divergent ones (matching the TS SDK)."""
202+
low_level = getattr(server, "_mcp_server", None) or getattr(
203+
server, "_lowlevel_server", None
204+
)
184205
return low_level if low_level is not None else server
185206

186207

@@ -197,8 +218,9 @@ def instrument(
197218
state instead of double-wrapping. Degrades to a no-op handle on any failure so
198219
the host application keeps working.
199220
200-
:param server: A ``FastMCP`` server (official ``mcp.server.fastmcp`` or jlowin's
201-
``fastmcp`` 2.0) or a low-level ``mcp.server.Server``.
221+
:param server: A high-level server — SDK 1.x ``mcp.server.fastmcp.FastMCP``,
222+
SDK 2.x ``mcp.server.mcpserver.MCPServer``, or jlowin's ``fastmcp.FastMCP``
223+
— or a low-level ``mcp.server.lowlevel.Server`` (either SDK major).
202224
:param posthog_client: A posthog ``Client`` you construct and own (call
203225
``shutdown()`` on exit to flush). Falls back to the global client.
204226
:param options: Optional :class:`MCPAnalyticsOptions`.
@@ -222,13 +244,20 @@ def instrument(
222244
"(PostHogMCP for custom dispatchers works without it.)"
223245
)
224246
_warn_if_unsupported_mcp_version()
225-
from ._compatibility import is_fastmcp, is_fastmcp_v2, is_low_level_server
226-
from ._instrument_fastmcp import instrument_fastmcp
227-
from ._instrument_lowlevel import instrument_fastmcp_v2, instrument_low_level
228247

229248
key = _canonical_server(server)
230249

231250
try:
251+
# Imported inside the try: the adapters touch major-specific modules, and
252+
# an import error must degrade to the no-op handle, not crash the host.
253+
from ._compatibility import (
254+
is_fastmcp,
255+
is_fastmcp_v2,
256+
is_low_level_server,
257+
is_mcpserver,
258+
uses_v2_handler_registry,
259+
)
260+
232261
client = _resolve_client(posthog_client)
233262
if client is None:
234263
log("Warning: no PostHog client available; MCP events will not be sent.")
@@ -242,15 +271,31 @@ def instrument(
242271
set_server_tracking_data(key, data)
243272

244273
if is_fastmcp(server):
274+
from ._instrument_fastmcp import instrument_fastmcp
275+
245276
instrument_fastmcp(server, data)
277+
elif is_mcpserver(server):
278+
from ._instrument_v2 import instrument_mcpserver_v2
279+
280+
instrument_mcpserver_v2(server, data)
246281
elif is_fastmcp_v2(server):
282+
from ._instrument_lowlevel import instrument_fastmcp_v2
283+
247284
instrument_fastmcp_v2(server, data)
248285
elif is_low_level_server(server):
249-
instrument_low_level(server, data)
286+
if uses_v2_handler_registry(server):
287+
from ._instrument_v2 import instrument_lowlevel_v2
288+
289+
instrument_lowlevel_v2(server, data)
290+
else:
291+
from ._instrument_lowlevel import instrument_low_level
292+
293+
instrument_low_level(server, data)
250294
else:
251295
raise TypeError(
252-
f"Unsupported server type: {type(server)!r}. Pass a FastMCP (official or jlowin's "
253-
"fastmcp 2.0) or a low-level mcp.server.Server."
296+
f"Unsupported server type: {type(server)!r}. Pass a high-level server "
297+
"(mcp.server.fastmcp.FastMCP on SDK 1.x, mcp.server.mcpserver.MCPServer "
298+
"on SDK 2.x, or jlowin's fastmcp.FastMCP) or a low-level mcp.server.Server."
254299
)
255300

256301
# Zero-config stateless minting: wrap the server's ASGI-app factories so a

posthog/mcp/_compatibility.py

Lines changed: 43 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,21 +2,40 @@
22
# Copyright (c) 2025 MCPcat
33
# Licensed under the MIT License: https://github.com/MCPCat/mcpcat-typescript-sdk/blob/main/LICENSE
44

5-
"""Detect which kind of MCP server was passed to ``instrument()``."""
5+
"""Detect which kind of MCP server was passed to ``instrument()``.
6+
7+
Every probe is import-tolerant: the classes live at different paths per MCP SDK
8+
major (``mcp.server.fastmcp.FastMCP`` on 1.x, ``mcp.server.mcpserver.MCPServer``
9+
on 2.x), so a probe whose class doesn't exist on the installed major answers
10+
``False`` instead of raising — an unconditional import here is exactly what
11+
would crash ``instrument()`` on the other major.
12+
"""
613

714
from __future__ import annotations
815

916
from typing import Any
1017

11-
from mcp.server.fastmcp import FastMCP
12-
from mcp.server.lowlevel import Server as LowLevelServer
13-
1418

1519
def is_fastmcp(server: Any) -> bool:
16-
"""The official SDK's high-level server (``mcp.server.fastmcp.FastMCP``)."""
20+
"""The MCP SDK 1.x high-level server (``mcp.server.fastmcp.FastMCP``).
21+
The module was renamed in 2.x, so this is False whenever mcp>=2 is installed."""
22+
try:
23+
from mcp.server.fastmcp import FastMCP
24+
except ImportError:
25+
return False
1726
return isinstance(server, FastMCP)
1827

1928

29+
def is_mcpserver(server: Any) -> bool:
30+
"""The MCP SDK 2.x high-level server (``mcp.server.mcpserver.MCPServer``,
31+
the renamed FastMCP). False whenever mcp<2 is installed."""
32+
try:
33+
from mcp.server.mcpserver import MCPServer
34+
except ImportError:
35+
return False
36+
return isinstance(server, MCPServer)
37+
38+
2039
def is_fastmcp_v2(server: Any) -> bool:
2140
"""jlowin's standalone FastMCP 2.0 (``fastmcp.FastMCP``), a separate package
2241
from the official SDK. Returns False if ``fastmcp`` isn't installed."""
@@ -28,4 +47,23 @@ def is_fastmcp_v2(server: Any) -> bool:
2847

2948

3049
def is_low_level_server(server: Any) -> bool:
50+
"""The low-level ``mcp.server.lowlevel.Server`` — the import path is the
51+
same on both majors; use :func:`uses_v2_handler_registry` to tell which
52+
handler seam it carries."""
53+
try:
54+
from mcp.server.lowlevel import Server as LowLevelServer
55+
except ImportError:
56+
return False
3157
return isinstance(server, LowLevelServer)
58+
59+
60+
def uses_v2_handler_registry(server: Any) -> bool:
61+
"""Which major's handler seam a low-level server carries, decided by shape
62+
rather than package version (a la posthog-js ADR-0005): 1.x exposes the
63+
public ``request_handlers`` dict keyed by request class; 2.x replaced it
64+
with ``add_request_handler``/``get_request_handler`` keyed by method string."""
65+
if hasattr(server, "add_request_handler") and hasattr(
66+
server, "get_request_handler"
67+
):
68+
return True
69+
return not hasattr(server, "request_handlers")

posthog/mcp/_conversation_id.py

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from __future__ import annotations
1111

1212
import copy
13+
import re
1314
from typing import Any, Dict, Optional, Tuple
1415

1516
from .constants import DEFAULT_CONVERSATION_ID_DESCRIPTION
@@ -18,6 +19,16 @@
1819

1920
CONVERSATION_ID_PARAM_NAME = "conversation_id"
2021

22+
# The shape of every id we mint: a uuidv7. Used to tell an echo of our own
23+
# handle from a value the agent made up. The shape check matters because the
24+
# handle becomes ``$session_id`` — without it, two unrelated users both sending
25+
# "conv-1" would share a session (byte-parity with posthog-js's
26+
# MINTED_CONVERSATION_ID).
27+
_MINTED_CONVERSATION_ID = re.compile(
28+
r"^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$",
29+
re.IGNORECASE,
30+
)
31+
2132

2233
def add_conversation_id_to_schema(
2334
input_schema: Optional[Dict[str, Any]], tool_name: str = "unknown"
@@ -71,20 +82,28 @@ def resolve_conversation_id(
7182
missing_capability_tool_name: str,
7283
) -> Tuple[Optional[str], bool]:
7384
"""Return ``(conversation_id, minted)``. Disabled or get_more_tools → ``(None, False)``;
74-
agent supplied → ``(value, False)``; agent omitted → ``(new uuid, True)``."""
85+
agent echoed a handle we could have minted → ``(value, False)``; anything
86+
else (omitted, or a value the agent made up) → ``(new uuid, True)``.
87+
88+
Lowercased on the way in: the shape test is case-insensitive but the hash
89+
behind ``$session_id`` is not, so an uppercased echo (some hosts normalise
90+
uuids) would land in a different session than the call that minted it."""
7591
if not enabled or tool_name == missing_capability_tool_name:
7692
return None, False
7793
supplied = extract_conversation_id(args)
78-
if supplied:
79-
return supplied, False
94+
if supplied and _MINTED_CONVERSATION_ID.match(supplied):
95+
return supplied.lower(), False
8096
return _uuid7(), True
8197

8298

8399
def can_inject_prompt_back(result: Any) -> bool:
100+
"""Whether the prompt-back can ride this result's ``content`` — the only
101+
requirement is a list to append to. Errored results included on purpose: a
102+
tool that fails on the first call of a conversation is exactly when the
103+
agent needs the handle, or the retry starts a fresh conversation and the
104+
failure and its fix land in different sessions."""
84105
if not isinstance(result, dict):
85106
return False
86-
if result.get("isError") is True:
87-
return False
88107
return isinstance(result.get("content"), list)
89108

90109

posthog/mcp/_instrument_fastmcp.py

Lines changed: 15 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,15 @@ async def wrapped(
101101
request = build_tool_call_request(name, arguments)
102102
extra: Dict[str, Any] = {"session_id": mcp_session_id}
103103

104+
# Resolve the conversation handle before the session: when the agent
105+
# carries (or is about to receive) one, it anchors $session_id for every
106+
# event of this request (ADR-0004) — the only correlation that survives
107+
# the 2026-07-28 revision's per-request server instances.
108+
missing_name = resolve_missing_capability_tool_name(data.options)
109+
conversation_id, minted = resolve_conversation_id(
110+
data.options.enable_conversation_id, arguments, name, missing_name
111+
)
112+
104113
session_id = await prepare_request(
105114
data,
106115
mcp_session_id=mcp_session_id,
@@ -110,9 +119,9 @@ async def wrapped(
110119
request=request,
111120
extra=extra,
112121
token=token,
122+
conversation_id=conversation_id,
113123
)
114124

115-
missing_name = resolve_missing_capability_tool_name(data.options)
116125
if data.options.report_missing and name == missing_name:
117126
await record_missing_capability(
118127
data,
@@ -129,10 +138,6 @@ async def wrapped(
129138
mcp_types.TextContent(type="text", text=get_more_tools_result_text())
130139
]
131140

132-
conversation_id, minted = resolve_conversation_id(
133-
data.options.enable_conversation_id, arguments, name, missing_name
134-
)
135-
136141
# Strip each injected key independently. A tool can declare its own
137142
# `context` (kept) while `conversation_id` is still SDK-injected (stripped),
138143
# so coupling both to context-ownership leaked conversation_id into the tool.
@@ -328,20 +333,18 @@ def _inject_prompt_back(result: Any, conversation_id: str) -> Any:
328333
"""Append the conversation_id prompt-back to a tool result so the agent echoes
329334
it on later calls. Handles every shape ToolManager.call_tool can return:
330335
a ``(content_list, structured)`` tuple (the convert_result=True production path),
331-
a bare content list, or a ``{content: [...]}`` dict. Returns the result unchanged
332-
(so the caller can detect non-delivery) for shapes we can't append to."""
336+
a bare content list, or a ``{content: [...]}`` dict — errored dicts included on
337+
purpose (a first-call failure is exactly when the agent needs the handle).
338+
Returns the result unchanged (so the caller can detect non-delivery) for
339+
shapes we can't append to."""
333340
block = mcp_types.TextContent(
334341
type="text", text=build_prompt_back(conversation_id)["text"]
335342
)
336343
if isinstance(result, tuple) and len(result) == 2 and isinstance(result[0], list):
337344
return ([*result[0], block], result[1])
338345
if isinstance(result, list):
339346
return [*result, block]
340-
if (
341-
isinstance(result, dict)
342-
and isinstance(result.get("content"), list)
343-
and not result.get("isError")
344-
):
347+
if isinstance(result, dict) and isinstance(result.get("content"), list):
345348
return {**result, "content": [*result["content"], block]}
346349
return result
347350

0 commit comments

Comments
 (0)