Skip to content

Commit 9d965dd

Browse files
fix(mcp): restore analytics capture for standalone FastMCP 4 (#936)
* fix(mcp): capture standalone FastMCP on the v2 registry Select the adapter by handler registry so standalone FastMCP 4 captures tool calls without changing the customer's instrument() invocation. Preserve application-owned arguments, including mounted tools, and use native v2 model fields when FastMCP exposes deprecated v1 aliases. Add HTTP regression coverage for both protocol eras, late tool registration, repeat instrumentation, failed tools, unavailable capture, and mounted argument ownership. Include FastMCP 4 in the existing MCP v2 CI matrix, document adapter behavior, and add a Sampo patch changeset. Validation on current main: MCP v1 suite 307 passed; MCP v2 suite 291 passed, 13 skipped. Ruff lint/format, filtered mypy (231 source files), import warning check, and actionlint passed. Real stdio tools emitted HTTP capture batches to a local receiver under both protocol eras, two events per run. Authenticated wizard installation and hosted ingestion remain untested. * 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. * fix(mcp): strip FastMCP analytics arguments from what was advertised Decide which analytics parameters to remove before dispatch from the tools/list the process served, recorded per tool in mutate_tool_schema, instead of asking FastMCP for the tool schema on every call. The live lookup remains only for tools never listed here or when the client pins a tool version, and now logs when it fails. Tools supplied by ToolInjectionMiddleware are listed but not resolvable via get_tool(), so the per-call lookup advertised `context` and then forwarded it, failing validation. Tested: posthog/test/mcp under mcp 2.2.0 + fastmcp 4.0.3 (297 passed, 13 skipped, new middleware test red before the fix) and under mcp 1.30.0 + fastmcp 3.2.0 (307 passed); ruff, mypy-baseline clean. Reviewer note: tool_model_parameter_injected still exists alongside the new tool_injected_parameters set because four adapters read it; folding it in is a follow-up. Claude-Session: https://claude.ai/code/session_015wZCNFdfPNK9k4utwJ5bMu * chore(mcp): verify middleware dispatch without schema lookup Extend FastMCP HTTP coverage to prove middleware tools dispatch when get_tool returns None. Exercise calls before listing, all advertised analytics arguments after listing, and application-owned arguments in both paths. Document why unknown arguments remain intact. Validation: MCP v1 suite 307 passed; MCP v2 suite 299 passed, 13 skipped. Ruff check and format checks passed for MCP source and tests. Repository-wide filtered mypy passed for 231 source files. * fix(mcp): resolve FastMCP argument ownership per request Remove the server-wide injected-parameter cache. Resolve effective schemas with the current request context, middleware, and requested tool version before stripping analytics arguments. Avoid using cached model ownership when schema resolution fails. Add interleaved-client regression cases for dynamic providers, middleware tools, and middleware overrides in both listing orders. The provider and middleware cases reproduced a missing application-owned llm_model argument before the fix. Document the per-call schema lookup. Validation: MCP v1 suite 307 passed; MCP v2 suite 305 passed, 13 skipped. Ruff check and format passed for MCP source and tests. Repository-wide filtered mypy passed for 231 files.
1 parent de46cd2 commit 9d965dd

12 files changed

Lines changed: 479 additions & 35 deletions

File tree

‎.github/workflows/ci.yml‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -155,10 +155,11 @@ jobs:
155155
# The MCP suite as a named gate per MCP Python SDK major. The v1 leg uses
156156
# the lockfile's mcp 1.x (also exercised incidentally by the `tests`
157157
# matrix — this leg exists as an explicit, named signal); the v2 leg
158-
# (spec 2026-07-28) swaps in mcp>=2 and drops jlowin fastmcp, which pins
159-
# mcp<2. posthog/test/mcp/conftest.py splits collection by major.
158+
# (spec 2026-07-28) swaps in mcp>=2 and standalone FastMCP 4, which uses
159+
# the v2 registry. posthog/test/mcp/conftest.py splits collection by major.
160160
name: MCP SDK ${{ matrix.mcp-major }} (Python ${{ matrix.python-version }})
161161
runs-on: ubuntu-latest
162+
timeout-minutes: 20
162163
strategy:
163164
matrix:
164165
python-version: ['3.10', '3.14']
@@ -189,8 +190,8 @@ jobs:
189190
if: matrix.mcp-major == 'v2'
190191
shell: bash
191192
run: |
192-
uv pip uninstall --python $pythonLocation fastmcp
193-
uv pip install --python $pythonLocation 'mcp>=2,<3'
193+
uv pip uninstall --python "$pythonLocation" fastmcp
194+
uv pip install --python "$pythonLocation" 'mcp>=2,<3' 'fastmcp>=4,<5'
194195
195196
- name: Run MCP tests against SDK ${{ matrix.mcp-major }}
196197
run: |
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
pypi/posthog: patch
3+
---
4+
5+
Fix missing MCP analytics events with standalone FastMCP 4 while preserving tool arguments and compatibility with MCP SDK v1.

‎posthog/mcp/README.md‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,16 @@ Neither fires for stdio, for a correctly-wired server, or for a conversation-anc
256256
session. The instrument-time check can't see whether you added the middleware yourself
257257
(the app is already built by then), so ignore it if you did.
258258

259+
Standalone `fastmcp` 4 uses the MCP SDK v2 handler registry. `instrument()` detects
260+
that registry automatically and captures tool calls over stdio and streamable HTTP,
261+
including the stateless protocol. Mounted tools retain their own arguments; analytics
262+
parameters are removed before dispatch only when the tool does not declare them.
263+
Instrumenting both the wrapper and its underlying server works in either order.
264+
For versioned tools, argument ownership follows the version requested by the client.
265+
Each tool call resolves the schema through FastMCP's tool listing in the current request context, including middleware and session transforms.
266+
This adds a schema lookup per call so clients with different tool schemas cannot change how another client's arguments are handled.
267+
The same installation code continues to support standalone FastMCP 2.x/3.x on MCP SDK v1.
268+
259269
Two gaps worth knowing: jlowin's `fastmcp` 2.x/3.x doesn't expose the attribute the
260270
instrument-time check reads, so those servers get the runtime warning only. And the
261271
deprecated SSE transport is excluded — it keys sessions off a query parameter, and the

‎posthog/mcp/__init__.py‎

Lines changed: 26 additions & 16 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

@@ -276,48 +277,57 @@ def instrument(
276277
key = _canonical_server(server)
277278

278279
try:
279-
# Imported inside the try: the adapters touch major-specific modules, and
280-
# an import error must degrade to the no-op handle, not crash the host.
280+
# MCP is an optional peer: load adapters only when instrumentation is
281+
# requested, inside the no-crash boundary. Class probes stay major-specific.
281282
from ._compatibility import (
282283
is_fastmcp,
283284
is_fastmcp_v2,
284285
is_low_level_server,
285286
is_mcpserver,
286287
uses_v2_handler_registry,
287288
)
289+
from ._instrument_fastmcp import instrument_fastmcp
290+
from ._instrument_lowlevel import instrument_fastmcp_v2, instrument_low_level
291+
from ._instrument_v2 import instrument_lowlevel_v2, instrument_mcpserver_v2
288292

289293
client = _resolve_client(posthog_client)
290294
if client is None:
291295
log("Warning: no PostHog client available; MCP events will not be sent.")
292296

293-
if get_server_tracking_data(key) is not None:
297+
existing_data = get_server_tracking_data(key)
298+
data = existing_data
299+
if data is None:
300+
sink = McpEventSink(client) if client is not None else None
301+
data = MCPAnalyticsData(
302+
options=opts, sink=sink, session_id=new_session_id()
303+
)
304+
305+
if is_fastmcp_v2(server) and uses_v2_handler_registry(key):
306+
data.standalone_fastmcp = weakref.ref(server)
307+
308+
# A standalone FastMCP wrapper and its low-level server share one tracking
309+
# key, so instrumenting the second of the pair must still attach what only
310+
# that object provides: the wrapper's schema lookup and ASGI app factories.
311+
if existing_data is not None:
312+
autowire_stateless_mint(server)
294313
log("instrument() - server already instrumented, skipping initialization")
295314
return McpAnalytics(key)
296315

297-
sink = McpEventSink(client) if client is not None else None
298-
data = MCPAnalyticsData(options=opts, sink=sink, session_id=new_session_id())
299316
set_server_tracking_data(key, data)
300317

301318
if is_fastmcp(server):
302-
from ._instrument_fastmcp import instrument_fastmcp
303-
304319
instrument_fastmcp(server, data)
305320
elif is_mcpserver(server):
306-
from ._instrument_v2 import instrument_mcpserver_v2
307-
308321
instrument_mcpserver_v2(server, data)
309322
elif is_fastmcp_v2(server):
310-
from ._instrument_lowlevel import instrument_fastmcp_v2
311-
312-
instrument_fastmcp_v2(server, data)
323+
if uses_v2_handler_registry(server._mcp_server):
324+
instrument_lowlevel_v2(server._mcp_server, data)
325+
else:
326+
instrument_fastmcp_v2(server, data)
313327
elif is_low_level_server(server):
314328
if uses_v2_handler_registry(server):
315-
from ._instrument_v2 import instrument_lowlevel_v2
316-
317329
instrument_lowlevel_v2(server, data)
318330
else:
319-
from ._instrument_lowlevel import instrument_low_level
320-
321331
instrument_low_level(server, data)
322332
else:
323333
raise TypeError(

‎posthog/mcp/_exceptions.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ def _is_call_tool_result(value: Any) -> bool:
5656
dict or a pydantic model from the ``mcp`` SDK."""
5757
if isinstance(value, dict):
5858
return "isError" in value and isinstance(value.get("content"), list)
59-
return hasattr(value, "isError") and isinstance(
59+
return (hasattr(value, "is_error") or hasattr(value, "isError")) and isinstance(
6060
getattr(value, "content", None), list
6161
)
6262

‎posthog/mcp/_instrument_v2.py‎

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

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

3637
import mcp.types as mcp_types
3738

38-
from ._context_parameters import schema_has_param
39+
from ._context_parameters import is_context_enabled, schema_has_param
3940
from ._conversation_id import build_prompt_back
4041
from ._event_types import MCPAnalyticsEventType
4142
from ._instrumentation import (
@@ -109,7 +110,8 @@ def instrument_lowlevel_v2(server: Any, data: MCPAnalyticsData) -> None:
109110
"""Instrument a raw v2 low-level ``Server``. ``context`` is injected as an
110111
*optional* schema property and NOT stripped — the schema doubles as the
111112
call's validation surface, and a typical ``(ctx, params)`` handler ignores
112-
extra argument keys."""
113+
extra argument keys. For standalone FastMCP, the shared tracking state supplies
114+
the tool schemas so injected arguments are removed before validation."""
113115
data.server_name = getattr(server, "name", None)
114116
data.server_version = getattr(server, "version", None)
115117
_wrap_v2_call_tool(server, data)
@@ -422,6 +424,59 @@ def _deliver_conversation_id(
422424
# --- low-level: tools/call ------------------------------------------------------
423425

424426

427+
def _requested_tool_version(ctx: Any) -> Optional[str]:
428+
"""The FastMCP tool version a client pinned via request ``_meta``, if any."""
429+
try:
430+
# Standalone FastMCP is optional even when the official MCP SDK is installed.
431+
from fastmcp.server.dependencies import extract_version_spec
432+
433+
params = getattr(ctx, "params", None)
434+
meta = params.get("_meta") if isinstance(params, Mapping) else None
435+
return extract_version_spec(meta)
436+
except Exception: # noqa: BLE001 - version parsing must not prevent dispatch
437+
return None
438+
439+
440+
async def _standalone_injected_parameters(
441+
server: Any, data: MCPAnalyticsData, name: str, version: Optional[str]
442+
) -> Optional[FrozenSet[str]]:
443+
"""Resolve ownership in the current request, including middleware and versions.
444+
445+
Listings from other requests can have different application-owned parameters.
446+
Without a schema, stripping could delete application arguments.
447+
"""
448+
try:
449+
from fastmcp.utilities.versions import VersionSpec, version_sort_key
450+
451+
version_spec = VersionSpec(eq=version) if version else None
452+
# Middleware can shadow registered tools, so resolve the effective listing.
453+
candidates = [
454+
tool
455+
for tool in await server.list_tools()
456+
if tool.name == name
457+
and (version_spec is None or version_spec.matches(tool.version))
458+
]
459+
tool = max(candidates, key=version_sort_key, default=None)
460+
if tool is None:
461+
tool = await server.get_tool(name, version=version_spec)
462+
schema = getattr(tool, "parameters", None)
463+
except Exception as error: # noqa: BLE001 - schema lookup must not prevent dispatch
464+
log(f"PostHog MCP: could not resolve schema for tool {name!r} - {error}")
465+
return None
466+
if not isinstance(schema, dict):
467+
return None
468+
injected = set()
469+
if is_context_enabled(data.options.context):
470+
injected.add("context")
471+
if data.options.enable_conversation_id:
472+
injected.add("conversation_id")
473+
if is_capture_model_enabled(data.options.capture_model) and (
474+
can_inject_model_parameter(schema)
475+
):
476+
injected.add("llm_model")
477+
return frozenset(key for key in injected if not schema_has_param(schema, key))
478+
479+
425480
def _wrap_v2_call_tool(server: Any, data: MCPAnalyticsData) -> None:
426481
entry = server.get_request_handler(_CALL_METHOD)
427482
if entry is None or getattr(entry.handler, _WRAPPED_FLAG, False):
@@ -431,6 +486,21 @@ def _wrap_v2_call_tool(server: Any, data: MCPAnalyticsData) -> None:
431486
async def handler(ctx: Any, params: Any) -> Any:
432487
name = params.name
433488
arguments = dict(params.arguments or {})
489+
analytics_owns_model = data.tool_model_parameter_injected.get(name, False)
490+
standalone = data.standalone_fastmcp() if data.standalone_fastmcp else None
491+
if standalone is not None:
492+
version = _requested_tool_version(ctx)
493+
injected = await _standalone_injected_parameters(
494+
standalone, data, name, version
495+
)
496+
analytics_owns_model = injected is not None and "llm_model" in injected
497+
if injected is not None:
498+
call_arguments = {
499+
key: value
500+
for key, value in arguments.items()
501+
if key not in injected
502+
}
503+
params = params.model_copy(update={"arguments": call_arguments})
434504
token, client_name, client_version, protocol_version, mcp_session_id = (
435505
_resolve_ctx(ctx)
436506
)
@@ -439,9 +509,7 @@ async def handler(ctx: Any, params: Any) -> Any:
439509
name=name,
440510
arguments=arguments,
441511
request_meta=request_meta_from_context(ctx),
442-
allow_self_reported_model=data.tool_model_parameter_injected.get(
443-
name, False
444-
),
512+
allow_self_reported_model=analytics_owns_model,
445513
mcp_session_id=mcp_session_id,
446514
token=token,
447515
client_name=client_name,

‎posthog/mcp/_instrumentation.py‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -173,10 +173,10 @@ def is_tool_result_error(result: Any) -> bool:
173173
(wire JSON unchanged); check both shapes."""
174174
if isinstance(result, dict):
175175
return result.get("isError") is True or result.get("is_error") is True
176-
return (
177-
getattr(result, "isError", None) is True
178-
or getattr(result, "is_error", None) is True
179-
)
176+
is_error = getattr(result, "is_error", None)
177+
if is_error is not None:
178+
return is_error is True
179+
return getattr(result, "isError", None) is True
180180

181181

182182
def build_tool_call_request(

‎posthog/mcp/_internal.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,8 @@ class MCPAnalyticsData:
8989
initialized_sessions: "OrderedDict[str, None]" = field(default_factory=OrderedDict)
9090
server_name: Optional[str] = None
9191
server_version: Optional[str] = None
92+
# A strong wrapper reference would retain the low-level WeakKeyDictionary key.
93+
standalone_fastmcp: Optional["weakref.ReferenceType[Any]"] = None
9294
session_lock: asyncio.Lock = field(default_factory=asyncio.Lock)
9395

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

‎posthog/mcp/_output_instructions.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,8 @@
3737
_CONVERSATION_ID_FIELD_DESCRIPTION = "The server-issued conversation identifier."
3838

3939
# `outputSchema` on MCP SDK 1.x models, `output_schema` on 2.x (same wire field).
40-
_OUTPUT_SCHEMA_ATTRS = ("outputSchema", "output_schema")
41-
_STRUCTURED_CONTENT_ATTRS = ("structuredContent", "structured_content")
40+
_OUTPUT_SCHEMA_ATTRS = ("output_schema", "outputSchema")
41+
_STRUCTURED_CONTENT_ATTRS = ("structured_content", "structuredContent")
4242

4343

4444
def _read_attr(obj: Any, names: Tuple[str, ...]) -> Tuple[Optional[str], Any]:

‎posthog/test/mcp/conftest.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
"test_v2_mcpserver.py",
2727
"test_v2_lowlevel.py",
2828
"test_v2_wire_dual_era.py",
29+
"test_fastmcp_v4.py",
2930
]
3031

3132
collect_ignore = _V2_ONLY if MCP_MAJOR < 2 else _V1_ONLY

0 commit comments

Comments
 (0)