diff --git a/.sampo/changesets/mcp-meta-client-identity.md b/.sampo/changesets/mcp-meta-client-identity.md new file mode 100644 index 00000000..eaae34a3 --- /dev/null +++ b/.sampo/changesets/mcp-meta-client-identity.md @@ -0,0 +1,5 @@ +--- +pypi/posthog: minor +--- + +feat(mcp): capture client identity under the MCP 2026-07-28 revision, which removes the `initialize` handshake and carries the client name/version and protocol version in every request's `_meta` instead. `$mcp_client_name`, `$mcp_client_version`, and `$mcp_protocol_version` keep populating on both `mcp` 1.x (where the SDK ignores `_meta`, so we read it ourselves) and `mcp>=2` (where the SDK renamed the seams we read identity from). Legacy clients are unaffected. Also fixes tool-error detection on `mcp>=2`, where `CallToolResult.isError` is spelled `is_error` — without it every v2 tool error was recorded as a success. On `mcp>=2`, `tools/list` is not yet captured: that release replaces the `request_handlers` dispatch the listing seam hooks. diff --git a/posthog/mcp/__init__.py b/posthog/mcp/__init__.py index 3da91f7d..38274692 100644 --- a/posthog/mcp/__init__.py +++ b/posthog/mcp/__init__.py @@ -152,9 +152,13 @@ def _resolve_client(posthog_client: Optional[Client]) -> Optional[Client]: def _warn_if_unsupported_mcp_version() -> None: """The adapters hook private MCP SDK seams (``_tool_manager``, ``_mcp_server``, - ``request_handlers``) tested against ``mcp>=1.26,<2``. Since ``mcp`` is a peer + ``request_handlers``) tested against ``mcp>=1.26,<3``. Since ``mcp`` is a peer dependency we don't pin, advise at runtime when the installed version is outside - that range rather than failing hard (older/newer may still mostly work).""" + that range rather than failing hard (older/newer may still mostly work). + + On ``mcp>=2`` tool calls and client identity are captured, but ``tools/list`` + is not: that release replaces the ``request_handlers`` dispatch the listing + seam hooks.""" try: from importlib.metadata import version @@ -162,11 +166,16 @@ def _warn_if_unsupported_mcp_version() -> None: major, minor = (int(p) for p in installed.split(".")[:2]) except Exception: # noqa: BLE001 - never let a version probe break instrument() return - if (major, minor) < (1, 26) or major >= 2: + if (major, minor) < (1, 26) or major >= 3: log( - f"Warning: PostHog MCP analytics is tested against mcp>=1.26,<2; found {installed}. " + f"Warning: PostHog MCP analytics is tested against mcp>=1.26,<3; found {installed}. " "Instrumentation hooks private SDK internals and may behave unexpectedly." ) + elif major >= 2: + log( + f"Note: on mcp {installed}, tool calls are captured but tools/list is not " + "(that release replaces the request_handlers dispatch)." + ) def _canonical_server(server: Any) -> Any: diff --git a/posthog/mcp/_client_identity.py b/posthog/mcp/_client_identity.py new file mode 100644 index 00000000..eaa6f312 --- /dev/null +++ b/posthog/mcp/_client_identity.py @@ -0,0 +1,58 @@ +"""Client identity under the MCP 2026-07-28 (stateless) revision. + +That revision removes the ``initialize`` handshake and the ``Mcp-Session-Id`` +header (SEP-2575 / SEP-2567), so the client name/version and the protocol +version travel in every request's ``params._meta`` under the reverse-DNS keys +below instead of arriving once at ``initialize``. + +We spell the keys out rather than import them: ``mcp>=2`` exports them as +``CLIENT_INFO_META_KEY`` / ``PROTOCOL_VERSION_META_KEY``, but ``mcp`` is an +optional peer dependency here (``PostHogMCP`` works without it) and 1.x has no +such constants. The strings match the 2026-07-28 schema either way. +""" + +from __future__ import annotations + +from typing import Any, Optional, Tuple + +META_CLIENT_INFO_KEY = "io.modelcontextprotocol/clientInfo" +META_PROTOCOL_VERSION_KEY = "io.modelcontextprotocol/protocolVersion" + + +def _str(value: Any) -> Optional[str]: + """Non-empty strings only, so a client sending ``""`` or a non-string can't + blank out (or put junk over) a good value from the token or ``initialize``.""" + return value if isinstance(value, str) and value else None + + +def apply_meta_client_info( + source: Any, + client_name: Optional[str], + client_version: Optional[str], + protocol_version: Optional[str], +) -> Tuple[Optional[str], Optional[str], Optional[str]]: + """Layer any client identity from this request's ``_meta`` over values already + resolved from the transport or the session token. + + ``_meta`` wins: under the 2026-07-28 revision there is no ``initialize`` and + no session token to learn from, so it is the only per-request truth. Only the + fields the request actually carries override, so a legacy request (no + ``_meta``) leaves all three untouched.""" + try: + # A RequestContext carries `_meta` directly; a request nests it under + # `params`. `dict()` normalizes both SDK generations: `mcp>=2` hands over + # a plain dict, 1.x a `RequestParams.Meta` model whose undeclared + # reverse-DNS keys iterate out just the same. + params = getattr(source, "params", None) + meta = dict( + getattr(source, "meta", None) or getattr(params, "meta", None) or {} + ) + + client_info = meta.get(META_CLIENT_INFO_KEY) or {} + return ( + _str(client_info.get("name")) or client_name, + _str(client_info.get("version")) or client_version, + _str(meta.get(META_PROTOCOL_VERSION_KEY)) or protocol_version, + ) + except Exception: # noqa: BLE001 - `_meta` is client-controlled; never fatal + return client_name, client_version, protocol_version diff --git a/posthog/mcp/_compatibility.py b/posthog/mcp/_compatibility.py index 37b017df..a0140c85 100644 --- a/posthog/mcp/_compatibility.py +++ b/posthog/mcp/_compatibility.py @@ -8,13 +8,26 @@ from typing import Any -from mcp.server.fastmcp import FastMCP from mcp.server.lowlevel import Server as LowLevelServer +# The official SDK's high-level server, under both names it has shipped under: +# `mcp.server.fastmcp.FastMCP` on 1.x, renamed `mcp.server.mcpserver.MCPServer` +# in mcp>=2. Only one of these resolves on any given install, so import both +# tolerantly — a hard import of either breaks `posthog.mcp` on the other major. +_HIGH_LEVEL_SERVERS: tuple = () +for _module, _name in ( + ("mcp.server.fastmcp", "FastMCP"), + ("mcp.server.mcpserver", "MCPServer"), +): + try: + _HIGH_LEVEL_SERVERS += (getattr(__import__(_module, fromlist=[_name]), _name),) + except Exception: # noqa: BLE001 - absent on the other major version + pass + def is_fastmcp(server: Any) -> bool: - """The official SDK's high-level server (``mcp.server.fastmcp.FastMCP``).""" - return isinstance(server, FastMCP) + """The official SDK's high-level server, on either major version.""" + return bool(_HIGH_LEVEL_SERVERS) and isinstance(server, _HIGH_LEVEL_SERVERS) def is_fastmcp_v2(server: Any) -> bool: diff --git a/posthog/mcp/_exceptions.py b/posthog/mcp/_exceptions.py index bf6e6f42..d2888ea9 100644 --- a/posthog/mcp/_exceptions.py +++ b/posthog/mcp/_exceptions.py @@ -53,12 +53,13 @@ def _from_message(message: str) -> ErrorProperties: def _is_call_tool_result(value: Any) -> bool: """Detect a CallToolResult error (``{isError, content: [...]}``), whether a - dict or a pydantic model from the ``mcp`` SDK.""" + dict or a pydantic model from the ``mcp`` SDK. ``isError`` is spelled + ``is_error`` on mcp>=2.""" if isinstance(value, dict): - return "isError" in value and isinstance(value.get("content"), list) - return hasattr(value, "isError") and isinstance( - getattr(value, "content", None), list - ) + has_flag = "isError" in value or "is_error" in value + return has_flag and isinstance(value.get("content"), list) + has_flag = hasattr(value, "isError") or hasattr(value, "is_error") + return has_flag and isinstance(getattr(value, "content", None), list) def _extract_call_tool_result_message(result: Any) -> str: diff --git a/posthog/mcp/_instrument_fastmcp.py b/posthog/mcp/_instrument_fastmcp.py index 6fd871f2..dd165ff0 100644 --- a/posthog/mcp/_instrument_fastmcp.py +++ b/posthog/mcp/_instrument_fastmcp.py @@ -95,7 +95,11 @@ async def wrapped( mcp_session_id = _mcp_session_id(context) token, client_name, client_version, protocol_version = ( resolve_session_and_client( - mcp_session_id, client_name, client_version, protocol_version + mcp_session_id, + client_name, + client_version, + protocol_version, + meta_source=_context_request_context(context), ) ) request = build_tool_call_request(name, arguments) @@ -226,7 +230,11 @@ async def list_handler(req: Any) -> Any: mcp_session_id = _low_level_session_id(server) token, client_name, client_version, protocol_version = ( resolve_session_and_client( - mcp_session_id, client_name, client_version, protocol_version + mcp_session_id, + client_name, + client_version, + protocol_version, + meta_source=req, ) ) request = request_to_dict(req) @@ -413,11 +421,25 @@ def _low_level_protocol_version(server: Any) -> Optional[str]: return None +def _context_request_context(context: Any) -> Any: + """``Context.request_context`` raises off-request rather than returning + ``None``, so a bare ``getattr`` default isn't enough.""" + try: + return context.request_context + except Exception: # noqa: BLE001 + return None + + def _client_info(context: Any) -> Tuple[Optional[str], Optional[str]]: try: client_params = context.request_context.session.client_params - if client_params and client_params.clientInfo: - return client_params.clientInfo.name, client_params.clientInfo.version + # `clientInfo` on mcp 1.x; renamed `client_info` in mcp>=2, where the SDK + # synthesizes it from each request's `_meta` for us. + info = getattr(client_params, "clientInfo", None) or getattr( + client_params, "client_info", None + ) + if info: + return info.name, info.version except Exception: # noqa: BLE001 pass return None, None @@ -425,9 +447,15 @@ def _client_info(context: Any) -> Tuple[Optional[str], Optional[str]]: def _protocol_version(context: Any) -> Optional[str]: try: - client_params = context.request_context.session.client_params - if client_params: - return client_params.protocolVersion + # mcp>=2 puts the negotiated version straight on the request context. + ctx = context.request_context + version = getattr(ctx, "protocol_version", None) + if version: + return version + client_params = ctx.session.client_params + return getattr(client_params, "protocolVersion", None) or getattr( + client_params, "protocol_version", None + ) except Exception: # noqa: BLE001 pass return None diff --git a/posthog/mcp/_instrument_lowlevel.py b/posthog/mcp/_instrument_lowlevel.py index cbe6c500..d06c2af1 100644 --- a/posthog/mcp/_instrument_lowlevel.py +++ b/posthog/mcp/_instrument_lowlevel.py @@ -98,7 +98,11 @@ async def handler(req: Any) -> Any: mcp_session_id = _mcp_session_id(server) token, client_name, client_version, protocol_version = ( resolve_session_and_client( - mcp_session_id, client_name, client_version, protocol_version + mcp_session_id, + client_name, + client_version, + protocol_version, + meta_source=req, ) ) request = build_tool_call_request(name, arguments) @@ -238,7 +242,11 @@ async def handler(req: Any) -> Any: mcp_session_id = _mcp_session_id(server) token, client_name, client_version, protocol_version = ( resolve_session_and_client( - mcp_session_id, client_name, client_version, protocol_version + mcp_session_id, + client_name, + client_version, + protocol_version, + meta_source=req, ) ) request = request_to_dict(req) diff --git a/posthog/mcp/_instrumentation.py b/posthog/mcp/_instrumentation.py index 865e5473..e433128e 100644 --- a/posthog/mcp/_instrumentation.py +++ b/posthog/mcp/_instrumentation.py @@ -16,6 +16,7 @@ from typing import Any, Dict, List, Optional, Set from ._capture import capture_event +from ._client_identity import apply_meta_client_info from ._event_types import MCPAnalyticsEventType from ._exceptions import capture_exception from ._intent import resolve_tool_call_intent, set_event_intent @@ -108,10 +109,14 @@ def drain_pending_sync(timeout: Optional[float] = None) -> None: def is_tool_result_error(result: Any) -> bool: - """MCP tool results signal errors via ``isError: true`` rather than raising.""" + """MCP tool results signal errors via ``isError: true`` rather than raising + (spelled ``is_error`` on mcp>=2).""" if isinstance(result, dict): - return result.get("isError") is True - return getattr(result, "isError", None) is True + 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 + ) def build_tool_call_request( @@ -191,12 +196,19 @@ def resolve_session_and_client( client_name: Optional[str], client_version: Optional[str], protocol_version: Optional[str] = None, + meta_source: Any = None, ) -> tuple[Optional[SessionTokenPayload], Optional[str], Optional[str], Optional[str]]: """Decode a replayed ``Mcp-Session-Id`` value as a self-encoded session token, and backfill the client name/version/protocol version from it when the live transport supplied none (the stateless-pod case, where ``initialize`` was never seen here). + ``meta_source`` is anything carrying this request's ``params._meta`` (a + ``Context``, ``RequestContext``, or request object). Under the MCP 2026-07-28 + revision there is no ``initialize`` and no session token, so ``_meta`` is the + only source of client identity — it therefore takes precedence over both. For + legacy clients it is absent and nothing changes. + Returns ``(token, client_name, client_version, protocol_version)``; ``token`` is ``None`` when the header isn't one of our tokens (a plain UUID, JWT, or nothing).""" token = decode_session_id(raw_session_id) @@ -204,7 +216,12 @@ def resolve_session_and_client( client_name = client_name or token.client_name client_version = client_version or token.client_version protocol_version = protocol_version or token.protocol_version - return token, client_name, client_version, protocol_version + return ( + token, + *apply_meta_client_info( + meta_source, client_name, client_version, protocol_version + ), + ) async def prepare_request( diff --git a/posthog/test/mcp/test_client_identity.py b/posthog/test/mcp/test_client_identity.py new file mode 100644 index 00000000..83b1cd12 --- /dev/null +++ b/posthog/test/mcp/test_client_identity.py @@ -0,0 +1,292 @@ +"""Client identity read from a request's ``_meta`` (MCP 2026-07-28 revision). + +The 2026-07-28 stateless revision drops the ``initialize`` handshake and the +``Mcp-Session-Id`` header, so the client name/version and protocol version +arrive in every request's ``params._meta`` instead. These tests cover the +precedence rules against the session token and end-to-end passes through both +adapters. +""" + +import mcp.types as mcp_types + +from posthog.mcp import instrument +from posthog.mcp._client_identity import ( + META_CLIENT_INFO_KEY, + META_PROTOCOL_VERSION_KEY, + apply_meta_client_info, +) +from posthog.mcp._instrumentation import resolve_session_and_client +from posthog.mcp.session_token import SessionTokenPayload, encode_session_id +from posthog.test.mcp._helpers import ( + FakeClient, + events_named as _events, + flush_background as _flush, +) + +FULL_META = { + META_CLIENT_INFO_KEY: {"name": "codex", "version": "1.2.3"}, + META_PROTOCOL_VERSION_KEY: "2026-07-28", +} + + +def call_request(meta=None): + """A real ``CallToolRequest``, so we exercise the pydantic ``extra="allow"`` + path the SDK actually hands us rather than a hand-rolled stand-in.""" + params = {"name": "echo", "arguments": {"a": 1}} + if meta is not None: + params["_meta"] = meta + return mcp_types.CallToolRequest.model_validate( + {"method": "tools/call", "params": params} + ) + + +def apply(meta, name=None, version=None, protocol=None): + return apply_meta_client_info(call_request(meta), name, version, protocol) + + +# --- reading ----------------------------------------------------------------- + + +def test_reads_client_info_and_protocol_version_from_meta(): + assert apply(FULL_META) == ("codex", "1.2.3", "2026-07-28") + + +def test_ignores_a_request_without_meta(): + assert apply(None, "legacy", "0.0.1", "2025-11-25") == ( + "legacy", + "0.0.1", + "2025-11-25", + ) + + +def test_ignores_meta_without_the_recognized_keys(): + assert apply({"com.other/thing": 1}) == (None, None, None) + + +def test_ignores_empty_and_non_string_fields(): + """A client sending `""` or a non-string must not blank out a good value.""" + meta = { + META_CLIENT_INFO_KEY: {"name": "", "version": 42}, + META_PROTOCOL_VERSION_KEY: "", + } + assert apply(meta, "legacy", "0.0.1", "2025-11-25") == ( + "legacy", + "0.0.1", + "2025-11-25", + ) + + +def test_only_overrides_the_fields_the_request_carries(): + assert apply( + {META_PROTOCOL_VERSION_KEY: "2026-07-28"}, "legacy", "0.0.1", "2025-11-25" + ) == ("legacy", "0.0.1", "2026-07-28") + + +def test_progress_token_alongside_client_info_is_ignored(): + """`progressToken` is a declared field, so it lands outside `model_extra` — + make sure its presence doesn't shadow the reverse-DNS keys.""" + assert apply({**FULL_META, "progressToken": "p1"}) == ( + "codex", + "1.2.3", + "2026-07-28", + ) + + +def test_reads_from_a_list_tools_request(): + req = mcp_types.ListToolsRequest.model_validate( + {"method": "tools/list", "params": {"_meta": FULL_META}} + ) + assert apply_meta_client_info(req, None, None, None)[0] == "codex" + + +def test_reads_from_a_request_context(): + """The FastMCP call path passes a ``RequestContext``, which carries the meta + directly rather than nesting it under ``params``.""" + + class Ctx: + meta = mcp_types.RequestParams.Meta.model_validate(FULL_META) + + assert apply_meta_client_info(Ctx(), None, None, None) == ( + "codex", + "1.2.3", + "2026-07-28", + ) + + +def test_never_raises_on_an_unusable_source(): + assert apply_meta_client_info(None, "legacy", None, None) == ("legacy", None, None) + assert apply_meta_client_info(object(), "legacy", None, None) == ( + "legacy", + None, + None, + ) + + +def test_reads_a_plain_dict_meta(): + """`mcp>=2` drops `RequestParams.Meta` and hands `_meta` over as a plain + dict, so there is no `model_extra` to unwrap on that generation.""" + + class V2Params: + meta = FULL_META + + class V2Request: + params = V2Params() + + assert apply_meta_client_info(V2Request(), None, None, None) == ( + "codex", + "1.2.3", + "2026-07-28", + ) + + +def test_falls_back_on_a_malformed_client_info(): + """`_meta` is arbitrary client-controlled JSON, so `clientInfo` need not be an + object; the wrong shape must fall back rather than raise.""" + for bad in ([1, 2], "codex", 7): + assert apply({META_CLIENT_INFO_KEY: bad}, "legacy", "0.0.1", "2025-11-25") == ( + "legacy", + "0.0.1", + "2025-11-25", + ) + + +# --- precedence -------------------------------------------------------------- + + +def test_meta_wins_over_the_session_token(): + """A stale token from an earlier session must not shadow the identity the + client is asserting on this request.""" + token = encode_session_id( + SessionTokenPayload( + session_id="ses_abc", + client_name="stale-client", + client_version="0.0.1", + protocol_version="2025-11-25", + ) + ) + decoded, name, version, protocol = resolve_session_and_client( + token, None, None, None, meta_source=call_request(FULL_META) + ) + assert decoded is not None and decoded.session_id == "ses_abc" + assert (name, version, protocol) == ("codex", "1.2.3", "2026-07-28") + + +def test_token_still_backfills_when_meta_is_absent(): + token = encode_session_id( + SessionTokenPayload( + session_id="ses_abc", + client_name="claude", + client_version="2.0.0", + protocol_version="2025-11-25", + ) + ) + _, name, version, protocol = resolve_session_and_client( + token, None, None, None, meta_source=call_request() + ) + assert (name, version, protocol) == ("claude", "2.0.0", "2025-11-25") + + +def test_two_concurrent_requests_do_not_cross_attribute(): + """Identity is resolved per request, so one client's `_meta` can never leak + into a sibling request on the same multiplexed server.""" + a = apply({META_CLIENT_INFO_KEY: {"name": "codex", "version": "1.0.0"}}) + b = apply({META_CLIENT_INFO_KEY: {"name": "claude", "version": "2.0.0"}}) + assert a[0] == "codex" + assert b[0] == "claude" + + +# --- end to end -------------------------------------------------------------- + + +async def test_low_level_tool_call_stamps_meta_identity_on_events(): + """No initialize, no session token — the 2026-07-28 shape. Client identity + must still reach the captured event.""" + from mcp.server.lowlevel import Server + + server = Server("test-server") + + @server.call_tool() + async def call_tool(name: str, arguments: dict): + return [mcp_types.TextContent(type="text", text="ok")] + + client = FakeClient() + instrument(server, client) + + handler = server.request_handlers[mcp_types.CallToolRequest] + await handler(call_request(FULL_META)) + await _flush() + + calls = _events(client, "$mcp_tool_call") + assert calls + props = calls[0]["properties"] + assert props["$mcp_client_name"] == "codex" + assert props["$mcp_client_version"] == "1.2.3" + assert props["$mcp_protocol_version"] == "2026-07-28" + + # The synthesized initialize for the same session carries it too. + init = _events(client, "$mcp_initialize") + assert init + assert init[0]["properties"]["$mcp_client_name"] == "codex" + + +async def test_fastmcp_tool_call_stamps_meta_identity_from_request_context(): + """The FastMCP call path has no request object — only a ``Context``.""" + from mcp.server.fastmcp import FastMCP + + class StubContext: + """Stands in for a FastMCP ``Context``; only ``request_context.meta`` is + read on this path.""" + + class request_context: # noqa: N801 - mirrors the attribute it fakes + meta = mcp_types.RequestParams.Meta.model_validate(FULL_META) + + server = FastMCP("test-server") + + @server.tool() + def add(a: int, b: int) -> int: + return a + b + + client = FakeClient() + instrument(server, client) + + await server._tool_manager.call_tool( + "add", + {"a": 2, "b": 3, "context": "summing for the report"}, + context=StubContext(), + ) + await _flush() + + calls = _events(client, "$mcp_tool_call") + assert calls + props = calls[0]["properties"] + assert props["$mcp_client_name"] == "codex" + assert props["$mcp_client_version"] == "1.2.3" + assert props["$mcp_protocol_version"] == "2026-07-28" + + +async def test_fastmcp_tool_call_survives_a_context_used_off_request(): + """`Context.request_context` raises off-request; that must not surface as a + tool-call failure.""" + from mcp.server.fastmcp import FastMCP + + class ExplodingContext: + @property + def request_context(self): + raise ValueError("Context is not available outside of a request") + + server = FastMCP("test-server") + + @server.tool() + def add(a: int, b: int) -> int: + return a + b + + client = FakeClient() + instrument(server, client) + + result = await server._tool_manager.call_tool( + "add", {"a": 2, "b": 3}, context=ExplodingContext() + ) + await _flush() + + assert result == 5 + assert _events(client, "$mcp_tool_call") diff --git a/posthog/test/mcp/test_mcp_v2.py b/posthog/test/mcp/test_mcp_v2.py new file mode 100644 index 00000000..f11c5ad3 --- /dev/null +++ b/posthog/test/mcp/test_mcp_v2.py @@ -0,0 +1,81 @@ +"""Client identity on mcp>=2, which renamed the SDK seams this SDK hooks. + +Skipped on mcp 1.x, where `mcp.server.mcpserver` doesn't exist. Run with an +`mcp>=2` install to exercise it. +""" + +import pytest + +pytest.importorskip("mcp.server.mcpserver") + +import mcp # noqa: E402 +import mcp.types as mcp_types # noqa: E402 +from mcp.server.mcpserver import MCPServer # noqa: E402 + +from posthog.mcp import instrument # noqa: E402 +from posthog.test.mcp._helpers import ( # noqa: E402 + FakeClient, + events_named as _events, + flush_background as _flush, +) + +CLIENT = mcp_types.Implementation(name="codex", version="1.2.3") + + +def make_server(): + server = MCPServer("test-server") + + @server.tool() + def add(a: int, b: int) -> int: + return a + b + + @server.tool() + def boom() -> str: + raise ValueError("explode") + + return server + + +async def test_captures_client_identity_on_mcp_v2(): + """mcp>=2 renamed `client_params.clientInfo` to `client_info` and puts the + negotiated version on the request context, so identity must come from there.""" + server = make_server() + client = FakeClient() + instrument(server, client) + + async with mcp.Client(server, client_info=CLIENT) as c: + result = await c.call_tool("add", {"a": 1, "b": 2}) + assert not result.is_error + await _flush() + + calls = _events(client, "$mcp_tool_call") + assert calls + props = calls[0]["properties"] + assert props["$mcp_tool_name"] == "add" + assert props["$mcp_client_name"] == "codex" + assert props["$mcp_client_version"] == "1.2.3" + assert props["$mcp_protocol_version"] == "2026-07-28" + assert props["$mcp_is_error"] is False + + init = _events(client, "$mcp_initialize") + assert init and init[0]["properties"]["$mcp_client_name"] == "codex" + + +async def test_flags_tool_errors_on_mcp_v2(): + """`CallToolResult.isError` is spelled `is_error` on mcp>=2; without handling + both, every v2 tool error is recorded as a success.""" + server = make_server() + client = FakeClient() + instrument(server, client) + + async with mcp.Client(server, client_info=CLIENT) as c: + await c.call_tool("boom", {}) + await _flush() + + call = next( + e + for e in _events(client, "$mcp_tool_call") + if e["properties"]["$mcp_tool_name"] == "boom" + ) + assert call["properties"]["$mcp_is_error"] is True + assert _events(client, "$exception")