Skip to content

Commit fc5e617

Browse files
committed
fix(mcp): preserve tool arguments across FastMCP middleware
Skip model argument injection on standalone MCP SDK v1 servers when application middleware can alter listing or dispatch. Keep client metadata capture enabled, and invalidate earlier model ownership so a replacement tool retains its own llm_model argument. Document the middleware limitation in the README and changeset. Extend the existing shadowing test with listed, reordered, and late middleware cases, and verify a fresh replica executes advertised calls while still capturing client metadata. Validation: MCP v1 553 passed, 1 skipped; MCP v2 444 passed, 21 skipped. Ruff lint/format, mypy (239 files), public API snapshot, and CodeScene safeguard pass. CodeScene improves both runtime files; existing large-module and test-complexity warnings remain.
1 parent 0b5db84 commit fc5e617

5 files changed

Lines changed: 135 additions & 46 deletions

File tree

‎.sampo/changesets/mcp-analytics-defaults.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,5 @@ pypi/posthog: minor
33
---
44

55
Enable MCP model capture and conversation correlation by default. Advertised tool schemas gain an `llm_model` argument (never enforced at dispatch) and eligible tool results gain a conversation handle; `MCPAnalyticsOptions(capture_model=False, enable_conversation_id=False)` restores the previous shape. Fresh low-level instances now read the self-reported model instead of staying silent.
6+
7+
Standalone FastMCP on MCP SDK 1.x skips `llm_model` injection when application middleware can change tool listing or dispatch. Model metadata capture remains enabled; this prevents cold replicas from rejecting injected arguments and preserves replacement tools' own arguments.

‎posthog/mcp/README.md‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,15 @@ nothing (posthog-js ADR-0011: reads fail open, strips fail closed). A tool that
9292
`llm_model` on such an instance is therefore recorded under `$mcp_llm_model` until a listing says
9393
otherwise; `capture_model=False` or `before_send` are the escapes. High-level adapters and
9494
standalone `fastmcp.FastMCP` read ownership from the registered tool schema, so they are
95-
unaffected and need no prior listing.
95+
unaffected and need no prior listing when dispatch uses that registry.
96+
97+
On MCP SDK 1.x, standalone FastMCP application middleware can replace or reroute a tool.
98+
When it overrides a listing or dispatch hook, PostHog does not inject `llm_model`: a fresh
99+
replica cannot safely distinguish that field from a replacement tool's own argument.
100+
Client metadata capture remains enabled. This also applies to pass-through logging or
101+
authorization middleware with those hooks; argument-based model capture is skipped so an
102+
application-owned value cannot be mistaken for analytics. No additional catalog lookup runs
103+
during a tool call.
96104

97105
For a custom dispatcher, `PostHogMCP` enables the same option by default; pass request
98106
metadata through explicitly:

‎posthog/mcp/_instrument_lowlevel.py‎

Lines changed: 32 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ def instrument_fastmcp_v2(server: Any, data: MCPAnalyticsData) -> None:
8585
# schema that requires `context` contradicts the arguments the SDK actually
8686
# sees: under `FastMCP(strict_input_validation=True)` every call fails with
8787
# "'context' is a required property".
88-
_wrap_list_tools(low_level, data, context_required=False)
88+
_wrap_list_tools(low_level, data, context_required=False, high_level=server)
8989
_wrap_resource_requests(low_level, data)
9090

9191

@@ -327,14 +327,21 @@ async def handler(req: Any) -> Any:
327327

328328

329329
def _inject_tool_schemas(
330-
data: MCPAnalyticsData, tools: list, *, context_required: bool
330+
data: MCPAnalyticsData,
331+
tools: list,
332+
*,
333+
context_required: bool,
334+
high_level: Any = None,
331335
) -> None:
332336
"""Advertise the analytics parameters on a listing's tools, in place.
333337
334338
Runs on both the client-facing listing and the SDK's internal cache-
335339
population pass, so the schema the SDK validates against always matches the
336340
one we advertised — see the note in ``handler``.
337341
"""
342+
# Middleware can replace the registered tool on another replica. Without
343+
# proof of ownership there, advertising a field could break its validation.
344+
inject_model = high_level is None or not _dispatch_can_differ(high_level)
338345
verdicts: Dict[str, bool] = {}
339346
for tool in tools:
340347
schema = getattr(tool, "inputSchema", None)
@@ -345,6 +352,7 @@ def _inject_tool_schemas(
345352
owns_context=schema_has_param(schema, "context"),
346353
context_required=context_required,
347354
is_sdk_virtual_tool=False,
355+
inject_model=inject_model,
348356
)
349357
verdict = data.tool_model_parameter_injected.get(tool.name)
350358
if verdict is None:
@@ -357,7 +365,11 @@ def _inject_tool_schemas(
357365

358366

359367
def _wrap_list_tools(
360-
server: Any, data: MCPAnalyticsData, *, context_required: bool
368+
server: Any,
369+
data: MCPAnalyticsData,
370+
*,
371+
context_required: bool,
372+
high_level: Any = None,
361373
) -> None:
362374
handlers = server.request_handlers
363375
original = handlers.get(mcp_types.ListToolsRequest)
@@ -400,7 +412,9 @@ async def probe_raw_tool_names(_ctx: Any = None) -> Optional[Set[str]]:
400412
# without re-injecting here the next real call is rejected for sending the
401413
# `context` we advertised. Same reason the `req is None` branch below
402414
# injects.
403-
_inject_tool_schemas(data, tools, context_required=context_required)
415+
_inject_tool_schemas(
416+
data, tools, context_required=context_required, high_level=high_level
417+
)
404418
return advertised_tool_names(tools)
405419

406420
data.raw_tool_names_probe = probe_raw_tool_names
@@ -416,7 +430,9 @@ async def handler(req: Any) -> Any:
416430
if req is None:
417431
result = await original(req)
418432
tools = extract_tools(result)
419-
_inject_tool_schemas(data, tools, context_required=context_required)
433+
_inject_tool_schemas(
434+
data, tools, context_required=context_required, high_level=high_level
435+
)
420436
return result
421437

422438
client_name, client_version = _client_info(server)
@@ -464,7 +480,9 @@ async def handler(req: Any) -> Any:
464480
is_first_page=is_first_listing_page(getattr(req, "params", None)),
465481
)
466482

467-
_inject_tool_schemas(data, tools, context_required=context_required)
483+
_inject_tool_schemas(
484+
data, tools, context_required=context_required, high_level=high_level
485+
)
468486

469487
result = apply_virtual_tool_injection(
470488
result, injection, names, data, schema_field="inputSchema"
@@ -557,25 +575,18 @@ async def _standalone_ownership(
557575
and ``conversation_id`` are stripped unless the registered schema (or,
558576
without one, the function signature) declares them. A registry failure
559577
protects all keys; a missing registry entry retains the middleware fallback
560-
for ``context`` and ``conversation_id``. ``llm_model`` is judged by the effective listing first,
561-
because middleware can provide or shadow the tool the registry knows, then
562-
by the registry; with neither witness it stays and is still read — strips
563-
fail closed, reads fail open (posthog-js ADR-0011).
578+
for ``context`` and ``conversation_id``. Middleware that can replace a tool
579+
disables model injection and invalidates earlier model ownership. Otherwise
580+
the listing or registry decides; with neither witness the model argument
581+
stays and is still read (posthog-js ADR-0011).
564582
"""
565583
try:
566584
declared, model_injectable = await _registry_view(high_level, name, meta)
567-
registry_trusted = model_injectable is not None and not _dispatch_can_differ(
568-
high_level
569-
)
585+
model_ours = data.tool_model_parameter_injected.get(name, model_injectable)
586+
if _dispatch_can_differ(high_level):
587+
model_ours = False
570588
except Exception: # noqa: BLE001 - ownership inference must never prevent dispatch
571-
declared, model_injectable, registry_trusted = None, None, False
572-
listed = data.tool_model_parameter_injected.get(name)
573-
if listed is not None:
574-
model_ours: Optional[bool] = listed
575-
elif registry_trusted:
576-
model_ours = model_injectable
577-
else:
578-
model_ours = None
589+
declared, model_ours = None, None
579590
candidates = _injected_keys(data)
580591
strip = {k for k in candidates - {"llm_model"} if k not in (declared or set())}
581592
if "llm_model" in candidates and model_ours:

‎posthog/mcp/_instrumentation.py‎

Lines changed: 28 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -994,6 +994,7 @@ def mutate_tool_schema(
994994
owns_context: bool,
995995
context_required: bool,
996996
is_sdk_virtual_tool: bool,
997+
inject_model: bool = True,
997998
) -> None:
998999
"""Apply the common analytics schema pipeline and write it back in place.
9991000
@@ -1014,21 +1015,14 @@ def mutate_tool_schema(
10141015
get_context_description(data.options.context),
10151016
required=context_required,
10161017
)
1017-
if is_capture_model_enabled(data.options.capture_model):
1018-
model_was_injected = data.tool_model_parameter_injected.get(tool.name, False)
1019-
app_owns_model = (
1020-
schema_has_param(schema, "llm_model") and not model_was_injected
1021-
)
1022-
if not app_owns_model and not schema_has_param(schema, "llm_model"):
1023-
schema = add_model_parameter_to_schema(
1024-
schema,
1025-
tool.name,
1026-
get_model_description(data.options.capture_model),
1027-
required=context_required,
1028-
)
1029-
data.tool_model_parameter_injected[tool.name] = (
1030-
not app_owns_model and schema_has_param(schema, "llm_model")
1018+
if inject_model:
1019+
schema = _mutate_model_schema(
1020+
data, tool.name, schema, required=context_required
10311021
)
1022+
else:
1023+
# Discard any earlier registered-tool verdict: middleware may now
1024+
# advertise or dispatch an application-owned field with the same name.
1025+
data.tool_model_parameter_injected[tool.name] = False
10321026
if data.options.enable_conversation_id and not schema_has_param(
10331027
schema, "conversation_id"
10341028
):
@@ -1044,6 +1038,26 @@ def mutate_tool_schema(
10441038
)
10451039

10461040

1041+
def _mutate_model_schema(
1042+
data: MCPAnalyticsData, name: str, schema: Any, *, required: bool
1043+
) -> Any:
1044+
if not is_capture_model_enabled(data.options.capture_model):
1045+
return schema
1046+
model_was_injected = data.tool_model_parameter_injected.get(name, False)
1047+
app_owns_model = schema_has_param(schema, "llm_model") and not model_was_injected
1048+
if not app_owns_model and not schema_has_param(schema, "llm_model"):
1049+
schema = add_model_parameter_to_schema(
1050+
schema,
1051+
name,
1052+
get_model_description(data.options.capture_model),
1053+
required=required,
1054+
)
1055+
data.tool_model_parameter_injected[name] = not app_owns_model and schema_has_param(
1056+
schema, "llm_model"
1057+
)
1058+
return schema
1059+
1060+
10471061
def request_to_dict(req: Any) -> Dict[str, Any]:
10481062
"""Shape a request object into the JSON-RPC-ish dict the sanitizer expects."""
10491063
method = getattr(req, "method", None) or "tools/list"

‎posthog/test/mcp/test_fastmcp_v2.py‎

Lines changed: 64 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,44 @@ async def test_jlowin_call_strips_llm_model_and_records_it(listed):
184184
assert calls[0]["properties"]["$mcp_llm_model"] == "model-a"
185185

186186

187+
async def test_jlowin_middleware_defaults_work_across_fresh_instances(monkeypatch):
188+
from types import SimpleNamespace
189+
190+
from fastmcp.server.middleware import Middleware
191+
192+
from posthog.mcp import _instrument_lowlevel
193+
194+
monkeypatch.setattr(
195+
_instrument_lowlevel,
196+
"_request_context",
197+
lambda _: SimpleNamespace(meta={"x-codex-turn-metadata": {"model": "gpt-5"}}),
198+
)
199+
client = FakeClient()
200+
201+
class PassThrough(Middleware):
202+
async def on_call_tool(self, context, call_next):
203+
return await call_next(context)
204+
205+
def fresh():
206+
server = make_server()
207+
server.add_middleware(PassThrough())
208+
instrument(server, client)
209+
return server
210+
211+
listing = await _list(fresh())
212+
schema = listing.root.tools[0].inputSchema
213+
# A cold replica cannot distinguish pass-through from tool-replacing
214+
# middleware, so discovery must not request an argument it cannot strip.
215+
assert "llm_model" not in schema["properties"]
216+
result = await _call(fresh(), "add", {"a": 2, "b": 3, "context": "sum"})
217+
assert result.root.isError is False
218+
assert result.root.content[0].text == "5"
219+
await _flush()
220+
event = _events(client, "$mcp_tool_call")[0]["properties"]
221+
assert event["$mcp_llm_model"] == "gpt-5"
222+
assert event["$mcp_llm_model_source"] == "client_metadata"
223+
224+
187225
_OWN_MODEL = {"llm_model": {"type": "string"}}
188226

189227

@@ -401,8 +439,8 @@ async def run(self, arguments):
401439
@pytest.mark.parametrize("listed", [True, False], ids=["listed", "cold"])
402440
async def test_jlowin_middleware_provided_tool_keeps_its_llm_model(listed):
403441
# The registry does not know a middleware-provided tool, so the effective
404-
# listing is the witness for llm_model. Cold, with no witness at all, the
405-
# argument stays (strips fail closed) and is read (reads fail open).
442+
# listing may differ from dispatch. Model injection stays off and the
443+
# application argument stays intact, even without a prior listing.
406444
pytest.importorskip("fastmcp.server.middleware")
407445
from fastmcp.server.middleware.tool_injection import ToolInjectionMiddleware
408446
from fastmcp.tools import Tool
@@ -425,7 +463,7 @@ def route(prompt: str, llm_model: str) -> str:
425463
assert out.root.isError is False, out.root.content
426464
assert out.root.content[0].text == "own"
427465
recorded = _events(client, "$mcp_tool_call")[0]["properties"].get("$mcp_llm_model")
428-
assert recorded == (None if listed else "own")
466+
assert recorded is None
429467

430468

431469
@pytest.mark.parametrize(
@@ -434,16 +472,27 @@ def route(prompt: str, llm_model: str) -> str:
434472
("listing", True),
435473
("listing", False),
436474
("dispatch", False),
475+
("dispatch", True),
476+
("listing-last", True),
477+
("late-dispatch", False),
437478
("builtin-subclass", False),
438479
],
439-
ids=["listing-listed", "listing-cold", "dispatch-cold", "builtin-subclass-cold"],
480+
ids=[
481+
"listing-listed",
482+
"listing-cold",
483+
"dispatch-cold",
484+
"dispatch-listed",
485+
"listing-last",
486+
"late-dispatch",
487+
"builtin-subclass-cold",
488+
],
440489
)
441490
async def test_jlowin_middleware_shadowed_tool_keeps_its_llm_model(shadow, listed):
442491
# A registered tool without llm_model is shadowed by middleware serving one
443492
# that declares it, either by also advertising it or only at dispatch. The
444493
# registry would answer for the wrong tool, so with such middleware present
445-
# it is not trusted: the argument stays and, cold, is read fail-open; a
446-
# listing that advertised the shadowing tool settles it as the application's.
494+
# it is not trusted: the argument stays and is not read as a self-report,
495+
# regardless of listing order or a stale registered-tool verdict.
447496
pytest.importorskip("fastmcp.server.middleware")
448497
from fastmcp.server.middleware import Middleware
449498
from fastmcp.server.middleware.tool_injection import ToolInjectionMiddleware
@@ -461,6 +510,10 @@ def route(prompt: str, llm_model: str) -> str:
461510
shadowing = Tool.from_function(route)
462511

463512
class DispatchShadow(Middleware):
513+
async def on_list_tools(self, context, call_next):
514+
tools = await call_next(context)
515+
return [*tools, shadowing] if shadow == "listing-last" else tools
516+
464517
async def on_call_tool(self, context, call_next):
465518
if context.message.name == "route":
466519
return await shadowing.run(context.message.arguments or {})
@@ -480,14 +533,15 @@ async def on_call_tool(self, context, call_next):
480533
return await shadowing.run(context.message.arguments or {})
481534
return await call_next(context)
482535

536+
client = FakeClient()
537+
instrument(server, client)
538+
if shadow == "late-dispatch":
539+
await _list(server)
483540
server.add_middleware(
484541
ToolInjectionMiddleware(tools=[shadowing])
485542
if shadow == "listing"
486543
else DispatchShadow()
487544
)
488-
client = FakeClient()
489-
instrument(server, client)
490-
491545
if listed:
492546
await _list(server)
493547
out = await _call(
@@ -498,7 +552,7 @@ async def on_call_tool(self, context, call_next):
498552
assert out.root.isError is False, out.root.content
499553
assert out.root.content[0].text == "own"
500554
recorded = _events(client, "$mcp_tool_call")[0]["properties"].get("$mcp_llm_model")
501-
assert recorded == (None if listed else "own")
555+
assert recorded is None
502556

503557

504558
async def test_jlowin_without_dereferencing_a_root_ref_is_never_injected_into():

0 commit comments

Comments
 (0)