Skip to content

Commit 8865969

Browse files
committed
fix(mcp): preserve FastMCP schemas across setup order and versions
Keep a weak reference to the standalone FastMCP wrapper in shared tracking state so a later wrapper install enriches existing low-level instrumentation without wrapping handlers twice. Resolve schemas for the client-requested tool version before stripping analytics-owned arguments. Consolidate adapter imports at the optional MCP dependency boundary instead of repeating imports in dispatch branches. Keep FastMCP-specific imports lazy so the official SDK remains usable without that optional package. Validation: reproduced both setup-order failures and the versioned-tool argument failure before fixing them. MCP v1 suite 307 passed; MCP v2 suite 296 passed, 13 skipped. Ruff format/lint and filtered mypy passed. Fresh-process checks passed with MCP absent and with FastMCP absent.
1 parent 62bb16f commit 8865969

5 files changed

Lines changed: 86 additions & 46 deletions

File tree

‎posthog/mcp/README.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,8 @@ Standalone `fastmcp` 4 uses the MCP SDK v2 handler registry. `instrument()` dete
166166
that registry automatically and captures tool calls over stdio and streamable HTTP,
167167
including the stateless protocol. Mounted tools retain their own arguments; analytics
168168
parameters are removed before dispatch only when the tool does not declare them.
169+
Instrumenting both the wrapper and its underlying server works in either order.
170+
For versioned tools, argument ownership follows the version requested by the client.
169171
The same installation code continues to support standalone FastMCP 2.x/3.x on MCP SDK v1.
170172

171173
Two gaps worth knowing: jlowin's `fastmcp` 2.x/3.x doesn't expose the attribute the

‎posthog/mcp/__init__.py‎

Lines changed: 20 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131

3232
from __future__ import annotations
3333

34+
import weakref
3435
from datetime import datetime, timezone
3536
from typing import Any, Optional
3637

@@ -257,55 +258,54 @@ def instrument(
257258
key = _canonical_server(server)
258259

259260
try:
260-
# Imported inside the try: the adapters touch major-specific modules, and
261-
# an import error must degrade to the no-op handle, not crash the host.
261+
# MCP is an optional peer: load adapters only when instrumentation is
262+
# requested, inside the no-crash boundary. Class probes stay major-specific.
262263
from ._compatibility import (
263264
is_fastmcp,
264265
is_fastmcp_v2,
265266
is_low_level_server,
266267
is_mcpserver,
267268
uses_v2_handler_registry,
268269
)
270+
from ._instrument_fastmcp import instrument_fastmcp
271+
from ._instrument_lowlevel import instrument_fastmcp_v2, instrument_low_level
272+
from ._instrument_v2 import instrument_lowlevel_v2, instrument_mcpserver_v2
269273

270274
client = _resolve_client(posthog_client)
271275
if client is None:
272276
log("Warning: no PostHog client available; MCP events will not be sent.")
273277

274-
if get_server_tracking_data(key) is not None:
278+
existing_data = get_server_tracking_data(key)
279+
data = existing_data
280+
if data is None:
281+
sink = McpEventSink(client) if client is not None else None
282+
data = MCPAnalyticsData(
283+
options=opts, sink=sink, session_id=new_session_id()
284+
)
285+
286+
if is_fastmcp_v2(server) and uses_v2_handler_registry(key):
287+
data.standalone_fastmcp = weakref.ref(server)
288+
289+
if existing_data is not None:
290+
autowire_stateless_mint(server)
275291
log("instrument() - server already instrumented, skipping initialization")
276292
return McpAnalytics(key)
277293

278-
sink = McpEventSink(client) if client is not None else None
279-
data = MCPAnalyticsData(options=opts, sink=sink, session_id=new_session_id())
280294
set_server_tracking_data(key, data)
281295

282296
if is_fastmcp(server):
283-
from ._instrument_fastmcp import instrument_fastmcp
284-
285297
instrument_fastmcp(server, data)
286298
elif is_mcpserver(server):
287-
from ._instrument_v2 import instrument_mcpserver_v2
288-
289299
instrument_mcpserver_v2(server, data)
290300
elif is_fastmcp_v2(server):
291301
if uses_v2_handler_registry(server._mcp_server):
292-
from ._instrument_v2 import instrument_lowlevel_v2
293-
294-
instrument_lowlevel_v2(
295-
server._mcp_server, data, strip_injected_for=server
296-
)
302+
instrument_lowlevel_v2(server._mcp_server, data)
297303
else:
298-
from ._instrument_lowlevel import instrument_fastmcp_v2
299-
300304
instrument_fastmcp_v2(server, data)
301305
elif is_low_level_server(server):
302306
if uses_v2_handler_registry(server):
303-
from ._instrument_v2 import instrument_lowlevel_v2
304-
305307
instrument_lowlevel_v2(server, data)
306308
else:
307-
from ._instrument_lowlevel import instrument_low_level
308-
309309
instrument_low_level(server, data)
310310
else:
311311
raise TypeError(

‎posthog/mcp/_instrument_v2.py‎

Lines changed: 22 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
from __future__ import annotations
3232

3333
import time
34+
from collections.abc import Mapping
3435
from typing import Any, Dict, Optional, Tuple
3536

3637
import mcp.types as mcp_types
@@ -89,21 +90,17 @@ def instrument_mcpserver_v2(server: Any, data: MCPAnalyticsData) -> None:
8990
_patch_add_request_handler(low_level, data, wrap_call=False, high_level=server)
9091

9192

92-
def instrument_lowlevel_v2(
93-
server: Any, data: MCPAnalyticsData, *, strip_injected_for: Any = None
94-
) -> None:
93+
def instrument_lowlevel_v2(server: Any, data: MCPAnalyticsData) -> None:
9594
"""Instrument a raw v2 low-level ``Server``. ``context`` is injected as an
9695
*optional* schema property and NOT stripped — the schema doubles as the
9796
call's validation surface, and a typical ``(ctx, params)`` handler ignores
98-
extra argument keys. For standalone FastMCP, ``strip_injected_for`` supplies
97+
extra argument keys. For standalone FastMCP, the shared tracking state supplies
9998
the tool schemas so injected arguments are removed before validation."""
10099
data.server_name = getattr(server, "name", None)
101100
data.server_version = getattr(server, "version", None)
102-
_wrap_v2_call_tool(server, data, strip_injected_for=strip_injected_for)
101+
_wrap_v2_call_tool(server, data)
103102
_wrap_v2_list_tools(server, data, context_required=False)
104-
_patch_add_request_handler(
105-
server, data, wrap_call=True, strip_injected_for=strip_injected_for
106-
)
103+
_patch_add_request_handler(server, data, wrap_call=True)
107104

108105

109106
# --- registry plumbing ---------------------------------------------------------
@@ -115,12 +112,7 @@ def _replace_handler(server: Any, method: str, wrapped: Any, params_type: Any) -
115112

116113

117114
def _patch_add_request_handler(
118-
server: Any,
119-
data: MCPAnalyticsData,
120-
*,
121-
wrap_call: bool,
122-
high_level: Any = None,
123-
strip_injected_for: Any = None,
115+
server: Any, data: MCPAnalyticsData, *, wrap_call: bool, high_level: Any = None
124116
) -> None:
125117
"""Wrap ``add_request_handler`` so handlers registered *after* instrument()
126118
for the instrumented methods get wrapped too. Registrations for other
@@ -134,7 +126,7 @@ def add_request_handler(method: str, params_type: Any, handler: Any) -> None:
134126
if getattr(handler, _WRAPPED_FLAG, False):
135127
return
136128
if method == _CALL_METHOD and wrap_call:
137-
_wrap_v2_call_tool(server, data, strip_injected_for=strip_injected_for)
129+
_wrap_v2_call_tool(server, data)
138130
elif method == _LIST_METHOD:
139131
_wrap_v2_list_tools(
140132
server,
@@ -404,17 +396,24 @@ def _deliver_conversation_id(
404396
# --- low-level: tools/call ------------------------------------------------------
405397

406398

407-
async def _standalone_tool_schema(server: Any, name: str) -> Any:
399+
async def _standalone_tool_schema(server: Any, name: str, ctx: Any) -> Any:
408400
try:
409-
tool = await server.get_tool(name)
401+
# Standalone FastMCP is optional even when the official MCP SDK is installed.
402+
from fastmcp.server.dependencies import extract_version_spec
403+
from fastmcp.utilities.versions import VersionSpec
404+
405+
params = getattr(ctx, "params", None)
406+
meta = params.get("_meta") if isinstance(params, Mapping) else None
407+
version = extract_version_spec(meta)
408+
tool = await server.get_tool(
409+
name, version=VersionSpec(eq=version) if version else None
410+
)
410411
return getattr(tool, "parameters", None)
411412
except Exception: # noqa: BLE001 - schema lookup must not prevent dispatch
412413
return None
413414

414415

415-
def _wrap_v2_call_tool(
416-
server: Any, data: MCPAnalyticsData, *, strip_injected_for: Any = None
417-
) -> None:
416+
def _wrap_v2_call_tool(server: Any, data: MCPAnalyticsData) -> None:
418417
entry = server.get_request_handler(_CALL_METHOD)
419418
if entry is None or getattr(entry.handler, _WRAPPED_FLAG, False):
420419
return
@@ -424,8 +423,9 @@ async def handler(ctx: Any, params: Any) -> Any:
424423
name = params.name
425424
arguments = dict(params.arguments or {})
426425
analytics_owns_model = data.tool_model_parameter_injected.get(name, False)
427-
if strip_injected_for is not None:
428-
schema = await _standalone_tool_schema(strip_injected_for, name)
426+
standalone = data.standalone_fastmcp() if data.standalone_fastmcp else None
427+
if standalone is not None:
428+
schema = await _standalone_tool_schema(standalone, name, ctx)
429429
analytics_owns_model = (
430430
isinstance(schema, dict)
431431
and is_capture_model_enabled(data.options.capture_model)

‎posthog/mcp/_internal.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,8 @@ class MCPAnalyticsData:
8484
initialized_sessions: "OrderedDict[str, None]" = field(default_factory=OrderedDict)
8585
server_name: Optional[str] = None
8686
server_version: Optional[str] = None
87+
# A strong wrapper reference would retain the low-level WeakKeyDictionary key.
88+
standalone_fastmcp: Optional["weakref.ReferenceType[Any]"] = None
8789
session_lock: asyncio.Lock = field(default_factory=asyncio.Lock)
8890

8991
def mark_session_initialized(self, session_id: str) -> None:

‎posthog/test/mcp/test_fastmcp_v4.py‎

Lines changed: 40 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ async def rpc(http, protocol, method, params):
3838
params = dict(params)
3939
headers = legacy_headers()
4040
if protocol == MODERN_PROTOCOL_VERSION:
41-
params["_meta"] = modern_meta()
41+
params["_meta"] = {**modern_meta(), **params.get("_meta", {})}
4242
headers = modern_headers(method, params.get("name"))
4343
else:
4444
headers["mcp-protocol-version"] = protocol
@@ -68,12 +68,18 @@ async def initialize(http, protocol):
6868

6969

7070
@pytest.mark.parametrize("protocol", [LEGACY_PROTOCOL_VERSION, MODERN_PROTOCOL_VERSION])
71-
async def test_capture_success_failure_and_sink_outage(protocol):
71+
@pytest.mark.parametrize("order", ["wrapper_first", "lowlevel_first", "wrapper_twice"])
72+
async def test_capture_success_failure_and_sink_outage(protocol, order):
7273
server = FastMCP("example-server")
7374
sink = FakeClient()
7475
options = MCPAnalyticsOptions(enable_conversation_id=True, capture_model=True)
75-
instrument(server, sink, options)
76-
instrument(server, sink, options)
76+
targets = {
77+
"wrapper_first": [server, server._mcp_server],
78+
"lowlevel_first": [server._mcp_server, server],
79+
"wrapper_twice": [server, server],
80+
}
81+
for target in targets[order]:
82+
instrument(target, sink, options)
7783
received = []
7884

7985
@server.tool()
@@ -171,3 +177,33 @@ def echo(context: str, conversation_id: str, llm_model: str) -> str:
171177
calls = events_named(sink, "$mcp_tool_call")
172178
assert len(calls) == 1
173179
assert "$mcp_llm_model" not in calls[0]["properties"]
180+
181+
182+
async def test_preserve_parameters_of_requested_tool_version():
183+
server = FastMCP("example-versioned-tools")
184+
185+
@server.tool(name="echo", version="1")
186+
def older(text: str, context: str) -> str:
187+
return f"{text}|{context}"
188+
189+
@server.tool(name="echo", version="2")
190+
def newer(text: str) -> str:
191+
return text
192+
193+
sink = FakeClient()
194+
instrument(server, sink)
195+
async with wire(server) as http:
196+
result = await rpc(
197+
http,
198+
MODERN_PROTOCOL_VERSION,
199+
"tools/call",
200+
{
201+
"name": "echo",
202+
"arguments": {"text": "example", "context": "application-context"},
203+
"_meta": {"fastmcp": {"version": "1"}},
204+
},
205+
)
206+
assert not result.get("isError", False), result
207+
assert result["content"][0]["text"] == "example|application-context"
208+
await flush_background()
209+
assert len(events_named(sink, "$mcp_tool_call")) == 1

0 commit comments

Comments
 (0)