diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9057b3da3..7f2b83cf7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -155,10 +155,11 @@ jobs: # The MCP suite as a named gate per MCP Python SDK major. The v1 leg uses # the lockfile's mcp 1.x (also exercised incidentally by the `tests` # matrix — this leg exists as an explicit, named signal); the v2 leg - # (spec 2026-07-28) swaps in mcp>=2 and drops jlowin fastmcp, which pins - # mcp<2. posthog/test/mcp/conftest.py splits collection by major. + # (spec 2026-07-28) swaps in mcp>=2 and standalone FastMCP 4, which uses + # the v2 registry. posthog/test/mcp/conftest.py splits collection by major. name: MCP SDK ${{ matrix.mcp-major }} (Python ${{ matrix.python-version }}) runs-on: ubuntu-latest + timeout-minutes: 20 strategy: matrix: python-version: ['3.10', '3.14'] @@ -189,8 +190,8 @@ jobs: if: matrix.mcp-major == 'v2' shell: bash run: | - uv pip uninstall --python $pythonLocation fastmcp - uv pip install --python $pythonLocation 'mcp>=2,<3' + uv pip uninstall --python "$pythonLocation" fastmcp + uv pip install --python "$pythonLocation" 'mcp>=2,<3' 'fastmcp>=4,<5' - name: Run MCP tests against SDK ${{ matrix.mcp-major }} run: | diff --git a/.sampo/changesets/chivalrous-witch-goulven.md b/.sampo/changesets/chivalrous-witch-goulven.md new file mode 100644 index 000000000..381f48c48 --- /dev/null +++ b/.sampo/changesets/chivalrous-witch-goulven.md @@ -0,0 +1,5 @@ +--- +pypi/posthog: patch +--- + +Fix missing MCP analytics events with standalone FastMCP 4 while preserving tool arguments and compatibility with MCP SDK v1. diff --git a/posthog/mcp/README.md b/posthog/mcp/README.md index a032629f7..51c1a22bc 100644 --- a/posthog/mcp/README.md +++ b/posthog/mcp/README.md @@ -162,6 +162,16 @@ Neither fires for stdio, for a correctly-wired server, or for a conversation-anc session. The instrument-time check can't see whether you added the middleware yourself (the app is already built by then), so ignore it if you did. +Standalone `fastmcp` 4 uses the MCP SDK v2 handler registry. `instrument()` detects +that registry automatically and captures tool calls over stdio and streamable HTTP, +including the stateless protocol. Mounted tools retain their own arguments; analytics +parameters are removed before dispatch only when the tool does not declare them. +Instrumenting both the wrapper and its underlying server works in either order. +For versioned tools, argument ownership follows the version requested by the client. +Each tool call resolves the schema through FastMCP's tool listing in the current request context, including middleware and session transforms. +This adds a schema lookup per call so clients with different tool schemas cannot change how another client's arguments are handled. +The same installation code continues to support standalone FastMCP 2.x/3.x on MCP SDK v1. + Two gaps worth knowing: jlowin's `fastmcp` 2.x/3.x doesn't expose the attribute the instrument-time check reads, so those servers get the runtime warning only. And the deprecated SSE transport is excluded — it keys sessions off a query parameter, and the diff --git a/posthog/mcp/__init__.py b/posthog/mcp/__init__.py index 633459300..79f6ae53c 100644 --- a/posthog/mcp/__init__.py +++ b/posthog/mcp/__init__.py @@ -31,6 +31,7 @@ from __future__ import annotations +import weakref from datetime import datetime, timezone from typing import Any, Optional @@ -257,8 +258,8 @@ def instrument( key = _canonical_server(server) try: - # Imported inside the try: the adapters touch major-specific modules, and - # an import error must degrade to the no-op handle, not crash the host. + # MCP is an optional peer: load adapters only when instrumentation is + # requested, inside the no-crash boundary. Class probes stay major-specific. from ._compatibility import ( is_fastmcp, is_fastmcp_v2, @@ -266,39 +267,48 @@ def instrument( is_mcpserver, uses_v2_handler_registry, ) + from ._instrument_fastmcp import instrument_fastmcp + from ._instrument_lowlevel import instrument_fastmcp_v2, instrument_low_level + from ._instrument_v2 import instrument_lowlevel_v2, instrument_mcpserver_v2 client = _resolve_client(posthog_client) if client is None: log("Warning: no PostHog client available; MCP events will not be sent.") - if get_server_tracking_data(key) is not None: + existing_data = get_server_tracking_data(key) + data = existing_data + if data is None: + sink = McpEventSink(client) if client is not None else None + data = MCPAnalyticsData( + options=opts, sink=sink, session_id=new_session_id() + ) + + if is_fastmcp_v2(server) and uses_v2_handler_registry(key): + data.standalone_fastmcp = weakref.ref(server) + + # A standalone FastMCP wrapper and its low-level server share one tracking + # key, so instrumenting the second of the pair must still attach what only + # that object provides: the wrapper's schema lookup and ASGI app factories. + if existing_data is not None: + autowire_stateless_mint(server) log("instrument() - server already instrumented, skipping initialization") return McpAnalytics(key) - sink = McpEventSink(client) if client is not None else None - data = MCPAnalyticsData(options=opts, sink=sink, session_id=new_session_id()) set_server_tracking_data(key, data) if is_fastmcp(server): - from ._instrument_fastmcp import instrument_fastmcp - instrument_fastmcp(server, data) elif is_mcpserver(server): - from ._instrument_v2 import instrument_mcpserver_v2 - instrument_mcpserver_v2(server, data) elif is_fastmcp_v2(server): - from ._instrument_lowlevel import instrument_fastmcp_v2 - - instrument_fastmcp_v2(server, data) + if uses_v2_handler_registry(server._mcp_server): + instrument_lowlevel_v2(server._mcp_server, data) + else: + instrument_fastmcp_v2(server, data) elif is_low_level_server(server): if uses_v2_handler_registry(server): - from ._instrument_v2 import instrument_lowlevel_v2 - instrument_lowlevel_v2(server, data) else: - from ._instrument_lowlevel import instrument_low_level - instrument_low_level(server, data) else: raise TypeError( diff --git a/posthog/mcp/_exceptions.py b/posthog/mcp/_exceptions.py index bf6e6f424..f34f30c7b 100644 --- a/posthog/mcp/_exceptions.py +++ b/posthog/mcp/_exceptions.py @@ -56,7 +56,7 @@ def _is_call_tool_result(value: Any) -> bool: dict or a pydantic model from the ``mcp`` SDK.""" if isinstance(value, dict): return "isError" in value and isinstance(value.get("content"), list) - return hasattr(value, "isError") and isinstance( + return (hasattr(value, "is_error") or hasattr(value, "isError")) and isinstance( getattr(value, "content", None), list ) diff --git a/posthog/mcp/_instrument_v2.py b/posthog/mcp/_instrument_v2.py index c740ae273..6e9e6da43 100644 --- a/posthog/mcp/_instrument_v2.py +++ b/posthog/mcp/_instrument_v2.py @@ -31,11 +31,12 @@ from __future__ import annotations import time -from typing import Any, Dict, Optional, Tuple +from collections.abc import Mapping +from typing import Any, Dict, FrozenSet, Optional, Tuple import mcp.types as mcp_types -from ._context_parameters import schema_has_param +from ._context_parameters import is_context_enabled, schema_has_param from ._conversation_id import build_prompt_back from ._instrumentation import ( _to_jsonable, @@ -93,7 +94,8 @@ def instrument_lowlevel_v2(server: Any, data: MCPAnalyticsData) -> None: """Instrument a raw v2 low-level ``Server``. ``context`` is injected as an *optional* schema property and NOT stripped — the schema doubles as the call's validation surface, and a typical ``(ctx, params)`` handler ignores - extra argument keys.""" + extra argument keys. For standalone FastMCP, the shared tracking state supplies + the tool schemas so injected arguments are removed before validation.""" data.server_name = getattr(server, "name", None) data.server_version = getattr(server, "version", None) _wrap_v2_call_tool(server, data) @@ -394,6 +396,59 @@ def _deliver_conversation_id( # --- low-level: tools/call ------------------------------------------------------ +def _requested_tool_version(ctx: Any) -> Optional[str]: + """The FastMCP tool version a client pinned via request ``_meta``, if any.""" + try: + # Standalone FastMCP is optional even when the official MCP SDK is installed. + from fastmcp.server.dependencies import extract_version_spec + + params = getattr(ctx, "params", None) + meta = params.get("_meta") if isinstance(params, Mapping) else None + return extract_version_spec(meta) + except Exception: # noqa: BLE001 - version parsing must not prevent dispatch + return None + + +async def _standalone_injected_parameters( + server: Any, data: MCPAnalyticsData, name: str, version: Optional[str] +) -> Optional[FrozenSet[str]]: + """Resolve ownership in the current request, including middleware and versions. + + Listings from other requests can have different application-owned parameters. + Without a schema, stripping could delete application arguments. + """ + try: + from fastmcp.utilities.versions import VersionSpec, version_sort_key + + version_spec = VersionSpec(eq=version) if version else None + # Middleware can shadow registered tools, so resolve the effective listing. + candidates = [ + tool + for tool in await server.list_tools() + if tool.name == name + and (version_spec is None or version_spec.matches(tool.version)) + ] + tool = max(candidates, key=version_sort_key, default=None) + if tool is None: + tool = await server.get_tool(name, version=version_spec) + schema = getattr(tool, "parameters", None) + except Exception as error: # noqa: BLE001 - schema lookup must not prevent dispatch + log(f"PostHog MCP: could not resolve schema for tool {name!r} - {error}") + return None + if not isinstance(schema, dict): + return None + injected = set() + if is_context_enabled(data.options.context): + injected.add("context") + if data.options.enable_conversation_id: + injected.add("conversation_id") + if is_capture_model_enabled(data.options.capture_model) and ( + can_inject_model_parameter(schema) + ): + injected.add("llm_model") + return frozenset(key for key in injected if not schema_has_param(schema, key)) + + def _wrap_v2_call_tool(server: Any, data: MCPAnalyticsData) -> None: entry = server.get_request_handler(_CALL_METHOD) if entry is None or getattr(entry.handler, _WRAPPED_FLAG, False): @@ -403,6 +458,21 @@ def _wrap_v2_call_tool(server: Any, data: MCPAnalyticsData) -> None: async def handler(ctx: Any, params: Any) -> Any: name = params.name arguments = dict(params.arguments or {}) + analytics_owns_model = data.tool_model_parameter_injected.get(name, False) + standalone = data.standalone_fastmcp() if data.standalone_fastmcp else None + if standalone is not None: + version = _requested_tool_version(ctx) + injected = await _standalone_injected_parameters( + standalone, data, name, version + ) + analytics_owns_model = injected is not None and "llm_model" in injected + if injected is not None: + call_arguments = { + key: value + for key, value in arguments.items() + if key not in injected + } + params = params.model_copy(update={"arguments": call_arguments}) token, client_name, client_version, protocol_version, mcp_session_id = ( _resolve_ctx(ctx) ) @@ -411,9 +481,7 @@ async def handler(ctx: Any, params: Any) -> Any: name=name, arguments=arguments, request_meta=request_meta_from_context(ctx), - allow_self_reported_model=data.tool_model_parameter_injected.get( - name, False - ), + allow_self_reported_model=analytics_owns_model, mcp_session_id=mcp_session_id, token=token, client_name=client_name, diff --git a/posthog/mcp/_instrumentation.py b/posthog/mcp/_instrumentation.py index 328fc597f..68b3e7bee 100644 --- a/posthog/mcp/_instrumentation.py +++ b/posthog/mcp/_instrumentation.py @@ -164,10 +164,10 @@ def is_tool_result_error(result: Any) -> bool: (wire JSON unchanged); check both shapes.""" if isinstance(result, dict): return result.get("isError") is True or result.get("is_error") is True - return ( - getattr(result, "isError", None) is True - or getattr(result, "is_error", None) is True - ) + is_error = getattr(result, "is_error", None) + if is_error is not None: + return is_error is True + return getattr(result, "isError", None) is True def build_tool_call_request( diff --git a/posthog/mcp/_internal.py b/posthog/mcp/_internal.py index 34e547535..cb82aca33 100644 --- a/posthog/mcp/_internal.py +++ b/posthog/mcp/_internal.py @@ -84,6 +84,8 @@ class MCPAnalyticsData: initialized_sessions: "OrderedDict[str, None]" = field(default_factory=OrderedDict) server_name: Optional[str] = None server_version: Optional[str] = None + # A strong wrapper reference would retain the low-level WeakKeyDictionary key. + standalone_fastmcp: Optional["weakref.ReferenceType[Any]"] = None session_lock: asyncio.Lock = field(default_factory=asyncio.Lock) def mark_session_initialized(self, session_id: str) -> None: diff --git a/posthog/mcp/_output_instructions.py b/posthog/mcp/_output_instructions.py index e1fb084ae..d340f36ab 100644 --- a/posthog/mcp/_output_instructions.py +++ b/posthog/mcp/_output_instructions.py @@ -37,8 +37,8 @@ _CONVERSATION_ID_FIELD_DESCRIPTION = "The server-issued conversation identifier." # `outputSchema` on MCP SDK 1.x models, `output_schema` on 2.x (same wire field). -_OUTPUT_SCHEMA_ATTRS = ("outputSchema", "output_schema") -_STRUCTURED_CONTENT_ATTRS = ("structuredContent", "structured_content") +_OUTPUT_SCHEMA_ATTRS = ("output_schema", "outputSchema") +_STRUCTURED_CONTENT_ATTRS = ("structured_content", "structuredContent") def _read_attr(obj: Any, names: Tuple[str, ...]) -> Tuple[Optional[str], Any]: diff --git a/posthog/test/mcp/conftest.py b/posthog/test/mcp/conftest.py index 65c0e887d..d01802d00 100644 --- a/posthog/test/mcp/conftest.py +++ b/posthog/test/mcp/conftest.py @@ -25,6 +25,7 @@ "test_v2_mcpserver.py", "test_v2_lowlevel.py", "test_v2_wire_dual_era.py", + "test_fastmcp_v4.py", ] collect_ignore = _V2_ONLY if MCP_MAJOR < 2 else _V1_ONLY diff --git a/posthog/test/mcp/test_fastmcp_v4.py b/posthog/test/mcp/test_fastmcp_v4.py new file mode 100644 index 000000000..ac29c3a9f --- /dev/null +++ b/posthog/test/mcp/test_fastmcp_v4.py @@ -0,0 +1,347 @@ +"""Standalone FastMCP on the MCP SDK v2 registry, exercised through HTTP.""" + +from contextlib import asynccontextmanager + +import httpx +import pytest + +pytest.importorskip("fastmcp", minversion="4") + +from fastmcp import FastMCP # noqa: E402 +from fastmcp.server.dependencies import get_http_headers # noqa: E402 +from fastmcp.server.middleware import Middleware # noqa: E402 +from fastmcp.server.middleware.tool_injection import ToolInjectionMiddleware # noqa: E402 +from fastmcp.server.providers import Provider # noqa: E402 +from fastmcp.tools import Tool # noqa: E402 + +from posthog.mcp import MCPAnalyticsOptions, instrument # noqa: E402 +from posthog.test.mcp._helpers import ( # noqa: E402 + FakeClient, + events_named, + flush_background, +) +from posthog.test.mcp._helpers_v2 import ( # noqa: E402 + LEGACY_PROTOCOL_VERSION, + MODERN_PROTOCOL_VERSION, + legacy_headers, + modern_headers, + modern_meta, +) + + +@asynccontextmanager +async def wire(server): + app = server.http_app(json_response=True, stateless_http=True) + async with app.router.lifespan_context(app): + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=app), base_url="http://localhost" + ) as http: + yield http + + +async def rpc(http, protocol, method, params): + params = dict(params) + headers = legacy_headers() + if protocol == MODERN_PROTOCOL_VERSION: + params["_meta"] = {**modern_meta(), **params.get("_meta", {})} + headers = modern_headers(method, params.get("name")) + else: + headers["mcp-protocol-version"] = protocol + response = await http.post( + "/mcp", + headers=headers, + json={"jsonrpc": "2.0", "id": 1, "method": method, "params": params}, + ) + assert response.status_code == 200, response.text + body = response.json() + assert "error" not in body, body + return body["result"] + + +async def initialize(http, protocol): + if protocol == LEGACY_PROTOCOL_VERSION: + await rpc( + http, + protocol, + "initialize", + { + "protocolVersion": protocol, + "capabilities": {}, + "clientInfo": {"name": "example-client", "version": "1.0"}, + }, + ) + + +@pytest.mark.parametrize("protocol", [LEGACY_PROTOCOL_VERSION, MODERN_PROTOCOL_VERSION]) +@pytest.mark.parametrize("order", ["wrapper_first", "lowlevel_first", "wrapper_twice"]) +async def test_capture_success_failure_and_sink_outage(protocol, order): + server = FastMCP("example-server") + sink = FakeClient() + options = MCPAnalyticsOptions(enable_conversation_id=True, capture_model=True) + targets = { + "wrapper_first": [server, server._mcp_server], + "lowlevel_first": [server._mcp_server, server], + "wrapper_twice": [server, server], + } + for target in targets[order]: + instrument(target, sink, options) + received = [] + + @server.tool() + def add(a: int, b: int) -> str: + received.append({"a": a, "b": b}) + return str(a + b) + + @server.tool() + def fail() -> str: + raise ValueError("example failure") + + async with wire(server) as http: + await initialize(http, protocol) + listed = await rpc(http, protocol, "tools/list", {}) + schema = next(t for t in listed["tools"] if t["name"] == "add")["inputSchema"] + assert {"context", "conversation_id", "llm_model"} <= schema[ + "properties" + ].keys() + assert not {"context", "conversation_id", "llm_model"} & set( + schema.get("required", []) + ) + args = {"context": "example addition", "llm_model": "example-model"} + result = await rpc( + http, + protocol, + "tools/call", + {"name": "add", "arguments": {"a": 2, "b": 3, **args}}, + ) + assert not result.get("isError", False) + assert result["content"][0]["text"] == "5" + assert received == [{"a": 2, "b": 3}] + assert len(result["content"]) == 2 + failed = await rpc( + http, protocol, "tools/call", {"name": "fail", "arguments": args} + ) + assert failed["isError"] + await flush_background() + calls = events_named(sink, "$mcp_tool_call") + assert len(calls) == 2 + assert [c["properties"]["$mcp_is_error"] for c in calls] == [False, True] + assert all(c["properties"]["$mcp_protocol_version"] == protocol for c in calls) + assert calls[0]["properties"]["$mcp_intent"] == "example addition" + assert calls[0]["properties"]["$mcp_llm_model"] == "example-model" + + def unavailable(*args, **kwargs): + raise RuntimeError("example capture outage") + + sink.capture = unavailable + offline = await rpc( + http, protocol, "tools/call", {"name": "add", "arguments": {"a": 3, "b": 4}} + ) + assert not offline.get("isError", False) + assert offline["content"][0]["text"] == "7" + await flush_background() + + +@pytest.mark.parametrize("list_first", [False, True]) +@pytest.mark.parametrize("source", ["direct", "mounted", "middleware"]) +async def test_preserve_application_parameters(list_first, source): + child = FastMCP("example-tools") + + def echo(context: str, conversation_id: str, llm_model: str) -> str: + return f"{context}|{conversation_id}|{llm_model}" + + tool = Tool.from_function(echo) + if source == "middleware": + child.add_middleware(ToolInjectionMiddleware(tools=[tool])) + assert await child.get_tool("echo") is None + else: + child.add_tool(tool) + server = FastMCP("example-app") if source == "mounted" else child + if source == "mounted": + server.mount(child, namespace="shared") + name = "shared_echo" if source == "mounted" else "echo" + sink = FakeClient() + instrument( + server, + sink, + MCPAnalyticsOptions(enable_conversation_id=True, capture_model=True), + ) + async with wire(server) as http: + if list_first: + await rpc(http, MODERN_PROTOCOL_VERSION, "tools/list", {}) + result = await rpc( + http, + MODERN_PROTOCOL_VERSION, + "tools/call", + { + "name": name, + "arguments": { + "context": "own-context", + "conversation_id": "own-id", + "llm_model": "own-model", + }, + }, + ) + assert not result.get("isError", False) + assert result["content"][0]["text"] == "own-context|own-id|own-model" + await flush_background() + calls = events_named(sink, "$mcp_tool_call") + assert len(calls) == 1 + assert "$mcp_llm_model" not in calls[0]["properties"] + + +async def test_preserve_parameters_of_requested_tool_version(): + server = FastMCP("example-versioned-tools") + + @server.tool(name="echo", version="1") + def older(text: str, context: str) -> str: + return f"{text}|{context}" + + @server.tool(name="echo", version="2") + def newer(text: str) -> str: + return text + + sink = FakeClient() + instrument(server, sink) + async with wire(server) as http: + result = await rpc( + http, + MODERN_PROTOCOL_VERSION, + "tools/call", + { + "name": "echo", + "arguments": {"text": "example", "context": "application-context"}, + "_meta": {"fastmcp": {"version": "1"}}, + }, + ) + assert not result.get("isError", False), result + assert result["content"][0]["text"] == "example|application-context" + await flush_background() + assert len(events_named(sink, "$mcp_tool_call")) == 1 + + +async def test_strip_analytics_parameters_from_middleware_tools(): + def echo(text: str) -> str: + return text + + server = FastMCP("example-middleware-tools") + server.add_middleware(ToolInjectionMiddleware(tools=[Tool.from_function(echo)])) + assert await server.get_tool("echo") is None + sink = FakeClient() + instrument( + server, + sink, + MCPAnalyticsOptions(enable_conversation_id=True, capture_model=True), + ) + async with wire(server) as http: + unlisted = await rpc( + http, + MODERN_PROTOCOL_VERSION, + "tools/call", + {"name": "echo", "arguments": {"text": "before listing"}}, + ) + assert not unlisted.get("isError", False), unlisted + assert unlisted["content"][0]["text"] == "before listing" + listed = await rpc(http, MODERN_PROTOCOL_VERSION, "tools/list", {}) + schema = next(t for t in listed["tools"] if t["name"] == "echo")["inputSchema"] + assert {"context", "conversation_id", "llm_model"} <= schema[ + "properties" + ].keys() + result = await rpc( + http, + MODERN_PROTOCOL_VERSION, + "tools/call", + { + "name": "echo", + "arguments": { + "text": "example", + "context": "example intent", + "conversation_id": "example-conversation", + "llm_model": "example-model", + }, + }, + ) + assert not result.get("isError", False), result + assert result["content"][0]["text"] == "example" + await flush_background() + calls = events_named(sink, "$mcp_tool_call") + assert len(calls) == 2 + assert all(not call["properties"]["$mcp_is_error"] for call in calls) + assert calls[1]["properties"]["$mcp_intent"] == "example intent" + assert calls[1]["properties"]["$mcp_llm_model"] == "example-model" + + +@pytest.mark.parametrize("source", ["provider", "middleware", "middleware_shadow"]) +@pytest.mark.parametrize( + "listing_order", [("application", "analytics"), ("analytics", "application")] +) +async def test_tool_argument_ownership_isolated_between_clients(source, listing_order): + def application(context: str, conversation_id: str, llm_model: str) -> str: + return f"{context}|{conversation_id}|{llm_model}" + + def analytics() -> str: + return "analytics" + + tools = { + "application": Tool.from_function(application, name="echo"), + "analytics": Tool.from_function(analytics, name="echo"), + } + + def current_tool() -> Tool: + return tools[get_http_headers()["x-example-client"]] + + class ClientTools(Provider): + async def _list_tools(self): + return [current_tool()] + + async def _get_tool(self, name, version=None): + return current_tool() if name == "echo" else None + + class ClientMiddleware(Middleware): + async def on_list_tools(self, context, call_next): + return [current_tool()] + + async def on_call_tool(self, context, call_next): + return await current_tool().run(context.message.arguments or {}) + + server = FastMCP("example-client-tools") + if source == "provider": + server.add_provider(ClientTools()) + else: + if source == "middleware_shadow": + server.add_tool(tools["analytics"]) + server.add_middleware(ClientMiddleware()) + sink = FakeClient() + instrument( + server, + sink, + MCPAnalyticsOptions(enable_conversation_id=True, capture_model=True), + ) + async with wire(server) as http: + for client in listing_order: + http.headers["x-example-client"] = client + await rpc(http, MODERN_PROTOCOL_VERSION, "tools/list", {}) + for client, expected in ( + ("application", "own-context|own-id|own-model"), + ("analytics", "analytics"), + ): + http.headers["x-example-client"] = client + result = await rpc( + http, + MODERN_PROTOCOL_VERSION, + "tools/call", + { + "name": "echo", + "arguments": { + "context": "own-context", + "conversation_id": "own-id", + "llm_model": "own-model", + }, + }, + ) + assert not result.get("isError", False), result + assert result["content"][0]["text"] == expected + await flush_background() + calls = events_named(sink, "$mcp_tool_call") + assert len(calls) == 2 + assert "$mcp_llm_model" not in calls[0]["properties"] + assert calls[1]["properties"]["$mcp_llm_model"] == "own-model" diff --git a/posthog/test/mcp/test_output_instructions.py b/posthog/test/mcp/test_output_instructions.py index 0fd569606..656a82ec4 100644 --- a/posthog/test/mcp/test_output_instructions.py +++ b/posthog/test/mcp/test_output_instructions.py @@ -115,7 +115,7 @@ def test_mirrors_into_a_fastmcp_v1_tuple_result(): def test_mirrors_into_a_model_result_both_attr_shapes(): - for attr in ("structuredContent", "structured_content"): + for attr in ("structured_content", "structuredContent"): result = SimpleNamespace(**{attr: {"total": 7}}) _, delivered = mirror_instructions_into_structured_content(result, "conv-2") @@ -202,7 +202,7 @@ def test_mirror_does_not_mutate_a_shared_model_result(): # `structuredContent` on SDK 1.x, `structured_content` on 2.x (same wire field). def structured(result): - for attr in ("structuredContent", "structured_content"): + for attr in ("structured_content", "structuredContent"): if hasattr(result, attr): return getattr(result, attr) raise AssertionError("no structured content attribute")