From 174661defece43e4d4933c25e620f231735ee2b2 Mon Sep 17 00:00:00 2001 From: Georgis Andonis Date: Fri, 21 Aug 2026 12:13:27 +0300 Subject: [PATCH 1/2] feat(mcp): capture $mcp_client_user_agent and $mcp_vendor_client MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit clientInfo.name says which client *library* is calling, not which product. Anthropic reports "claude-code" from the CLI, the Agent SDK, the VS Code extension and the desktop app alike, so $mcp_client_name collapses every surface into one bucket — which is why the harness breakdown reads 100% "Other" for Python-backed servers, reported from the field. The distinguishing detail lives in the User-Agent parenthetical (claude-code/2.1.0 (cli) vs (sdk-ts) vs (claude-vscode)) and in vendor headers like x-anthropic-client. Both are captured raw and classified nowhere: friendly names resolve at query time, so labels improve and new surfaces appear without waiting on an SDK release, and there is one resolver rather than one per installed version. Read through get_request_headers, so it works identically on both SDK majors; HTTP transports only, so stdio and in-memory events stay byte-identical. Values are bounded by the existing metadata cap, so a hostile header cannot inflate an event, and the read is fully guarded — surface attribution must never break a tool call. Custom dispatchers hold their own request object, so every PostHogMCP.capture_* method takes client_user_agent / vendor_client directly. Also unifies how the v2 adapter reaches the request context: it read the private _request_context while v1 reads the public property. Both work, but the public read (guarded, since it raises outside a request) removes a private-attribute dependency and the asymmetry. Parity with @posthog/mcp's transport-identity module. Generated-By: PostHog Code Task-Id: ebafcb71-b03b-443d-b40c-d527ed4a04f4 --- .sampo/changesets/mcp-client-attribution.md | 5 + posthog/mcp/_capture.py | 2 + posthog/mcp/_instrument_v2.py | 16 +- posthog/mcp/_instrumentation.py | 5 + posthog/mcp/_posthog_events.py | 6 + posthog/mcp/_transport_identity.py | 75 +++++++++ posthog/mcp/_truncation.py | 2 + posthog/mcp/constants.py | 2 + posthog/mcp/posthog_mcp.py | 23 +++ posthog/test/mcp/test_transport_identity.py | 162 ++++++++++++++++++++ references/public_api_snapshot.txt | 10 +- 11 files changed, 303 insertions(+), 5 deletions(-) create mode 100644 .sampo/changesets/mcp-client-attribution.md create mode 100644 posthog/mcp/_transport_identity.py create mode 100644 posthog/test/mcp/test_transport_identity.py diff --git a/.sampo/changesets/mcp-client-attribution.md b/.sampo/changesets/mcp-client-attribution.md new file mode 100644 index 000000000..d17eb9b56 --- /dev/null +++ b/.sampo/changesets/mcp-client-attribution.md @@ -0,0 +1,5 @@ +--- +posthog: minor +--- + +feat(mcp): capture `$mcp_client_user_agent` and `$mcp_vendor_client` so MCP usage can be attributed to a product surface. `clientInfo.name` only says which client *library* is calling — Anthropic reports `claude-code` from the CLI, the Agent SDK, the VS Code extension and the desktop app alike — so `$mcp_client_name` collapses every surface into one bucket and the harness breakdown reads 100% "Other" for Python-backed servers. The distinguishing detail lives in the User-Agent parenthetical (`claude-code/2.1.0 (cli)` vs `(sdk-ts)`) and in vendor headers like `x-anthropic-client`. Both are captured raw and classified at query time, so labels can improve without an SDK release. HTTP transports only: stdio and in-memory servers carry no headers and their events are unchanged. Custom dispatchers pass their own via new `client_user_agent` / `vendor_client` arguments on every `PostHogMCP.capture_*` method. Parity with `@posthog/mcp`. diff --git a/posthog/mcp/_capture.py b/posthog/mcp/_capture.py index bba3a896f..4544ce5f3 100644 --- a/posthog/mcp/_capture.py +++ b/posthog/mcp/_capture.py @@ -50,6 +50,8 @@ def capture_event( "client_name": event_input.get("client_name"), "client_version": event_input.get("client_version"), "protocol_version": event_input.get("protocol_version"), + "client_user_agent": event_input.get("client_user_agent"), + "vendor_client": event_input.get("vendor_client"), "identify_actor_given_id": actor.distinct_id if actor else None, "identify_actor_data": (actor.properties or {}) if actor else {}, "groups": actor.groups if actor else None, diff --git a/posthog/mcp/_instrument_v2.py b/posthog/mcp/_instrument_v2.py index fc3cac941..f97e5043c 100644 --- a/posthog/mcp/_instrument_v2.py +++ b/posthog/mcp/_instrument_v2.py @@ -151,6 +151,20 @@ def add_request_handler(method: str, params_type: Any, handler: Any) -> None: # --- ctx readers ----------------------------------------------------------------- +def _request_context_of(context: Any) -> Any: + """The request context behind a v2 ``Context``, or ``None``. + + Reads the public property rather than the private ``_request_context`` it + wraps, guarded because it *raises* outside a request (the same trap the + FastMCP adapter hit). Falls back to the private attribute so a stand-in + object that only carries that still works. + """ + try: + return context.request_context + except (LookupError, ValueError, AttributeError): + return getattr(context, "_request_context", None) + + def _ctx_client_info(ctx: Any) -> Tuple[Optional[str], Optional[str]]: try: client_params = ctx.session.client_params @@ -243,7 +257,7 @@ async def wrapped( context: Any = None, convert_result: bool = False, ) -> Any: - ctx = getattr(context, "_request_context", None) + ctx = _request_context_of(context) token, client_name, client_version, protocol_version, mcp_session_id = ( _resolve_ctx(ctx) ) diff --git a/posthog/mcp/_instrumentation.py b/posthog/mcp/_instrumentation.py index 6f6a363ff..43f985711 100644 --- a/posthog/mcp/_instrumentation.py +++ b/posthog/mcp/_instrumentation.py @@ -23,6 +23,7 @@ from ._internal import MCPAnalyticsData, handle_identify, resolve_event_properties from .logger import log from ._sanitization import build_captured_mcp_parameters +from ._transport_identity import stamp_transport_identity from .session import resolve_session_id from .session_token import SessionTokenPayload, decode_session_id @@ -211,6 +212,7 @@ async def _maybe_emit_initialize( await _apply_event_properties( data, event, {"method": "initialize", "params": {}}, extra ) + stamp_transport_identity(event, extra) fire_and_forget(capture_event(data, event), data) @@ -364,6 +366,7 @@ async def record_tool_call( if props is not None: event["properties"] = props + stamp_transport_identity(event, extra) fire_and_forget(capture_event(data, event), data) except Exception as err: # noqa: BLE001 - isolate analytics from the tool path log(f"record_tool_call failed (event dropped, tool unaffected): {err}") @@ -458,6 +461,7 @@ async def record_missing_capability( event["user_intent"] = context.strip() event["user_intent_source"] = "context_parameter" await _apply_event_properties(data, event, request, extra) + stamp_transport_identity(event, extra) fire_and_forget(capture_event(data, event), data) except Exception as err: # noqa: BLE001 - isolate analytics from the tool path log(f"record_missing_capability failed (event dropped): {err}") @@ -495,6 +499,7 @@ async def record_tools_list( if error is not None: event["error"] = capture_exception(error) await _apply_event_properties(data, event, request, extra) + stamp_transport_identity(event, extra) fire_and_forget(capture_event(data, event), data) except Exception as err: # noqa: BLE001 - isolate analytics from the tool path log(f"record_tools_list failed (event dropped): {err}") diff --git a/posthog/mcp/_posthog_events.py b/posthog/mcp/_posthog_events.py index 309fa4040..a7c5b79c4 100644 --- a/posthog/mcp/_posthog_events.py +++ b/posthog/mcp/_posthog_events.py @@ -131,6 +131,12 @@ def _add_common_properties(event: Event, properties: Dict[str, Any]) -> None: properties[_P.CLIENT_NAME] = event["client_name"] if event.get("client_version"): properties[_P.CLIENT_VERSION] = event["client_version"] + # HTTP transports only, and only for the request that carried the header — + # stdio and in-memory servers simply never set these. + if event.get("client_user_agent"): + properties[_P.CLIENT_USER_AGENT] = event["client_user_agent"] + if event.get("vendor_client"): + properties[_P.VENDOR_CLIENT] = event["vendor_client"] if event.get("protocol_version"): properties[_P.PROTOCOL_VERSION] = event["protocol_version"] if event.get("user_intent"): diff --git a/posthog/mcp/_transport_identity.py b/posthog/mcp/_transport_identity.py new file mode 100644 index 000000000..24ddd4b52 --- /dev/null +++ b/posthog/mcp/_transport_identity.py @@ -0,0 +1,75 @@ +# Portions of this package are derived from MCPCat/mcpcat-typescript-sdk +# Copyright (c) 2025 MCPcat +# Licensed under the MIT License: https://github.com/MCPCat/mcpcat-typescript-sdk/blob/main/LICENSE + +"""Transport-level client identity: the two request headers that say *which +product* is calling, where ``clientInfo`` only says which client library is. + +MCP's own identity fields are too coarse to attribute usage to a surface. A +vendor ships many products on one client: Anthropic reports +``clientInfo.name = "claude-code"`` from the CLI, the Agent SDK, the VS Code +extension and the desktop app alike, so ``$mcp_client_name`` collapses all of +them into one bucket. The distinguishing detail lives in the User-Agent +parenthetical — ``claude-code/2.1.0 (cli)`` vs ``(sdk-ts)`` vs +``(claude-vscode)`` — and in vendor headers like ``x-anthropic-client``. +Capturing them is the only way a server owner can tell their surfaces apart. + +We capture the raw strings and classify nothing. No vendor table, no product +labels: friendly names are resolved at query time server-side, so labels can +improve (and new surfaces appear) without waiting on an SDK release. + +Deliberately separate from the client identity read out of the request body's +``_meta``, which works on every transport. Headers exist only on HTTP +transports, so everything here is a silent no-op on stdio and in-memory servers +and their events stay byte-identical to before. +""" + +from __future__ import annotations + +from typing import Any, Dict + +from .request_headers import get_request_headers + +__all__ = [ + "CLIENT_USER_AGENT_HEADER", + "VENDOR_CLIENT_HEADER", + "stamp_transport_identity", +] + +#: Header carrying the client's product/surface, e.g. ``claude-code/2.1.0 (cli)``. +CLIENT_USER_AGENT_HEADER = "user-agent" + +#: Vendor-specific client header. Anthropic's clients send it alongside the +#: User-Agent; captured verbatim as a second, independent signal rather than +#: merged into one, so a query-time resolver can prefer whichever the vendor +#: keeps stable. +VENDOR_CLIENT_HEADER = "x-anthropic-client" + + +def stamp_transport_identity(event: Dict[str, Any], extra: Any) -> None: + """Stamp the transport identity onto the event being built for *this* + request, so it carries ``$mcp_client_user_agent`` and ``$mcp_vendor_client``. + + Headers are per-request, so this writes to the event — a per-request object + — and never to server-wide state. One instrumented server multiplexes + concurrent requests from different clients, and caching a header into shared + state would attribute one client's surface to another's event. + + Values are capped downstream by truncation, which runs on every capture + path, so a hostile 1MB header cannot inflate an event. Never raises: a + header read must not take a tool call down with it. + """ + try: + headers = get_request_headers(extra) + except Exception: # noqa: BLE001 - defensive; get_request_headers is already guarded + return + if not headers: + return + + # get_request_headers lowercases keys, so a direct lookup is enough. + user_agent = headers.get(CLIENT_USER_AGENT_HEADER) + if user_agent: + event["client_user_agent"] = user_agent + vendor_client = headers.get(VENDOR_CLIENT_HEADER) + if vendor_client: + event["vendor_client"] = vendor_client diff --git a/posthog/mcp/_truncation.py b/posthog/mcp/_truncation.py index 3f45e6416..2d19c6564 100644 --- a/posthog/mcp/_truncation.py +++ b/posthog/mcp/_truncation.py @@ -42,6 +42,8 @@ ("client_name", _MAX_METADATA_LENGTH), ("client_version", _MAX_METADATA_LENGTH), ("error_type", _MAX_METADATA_LENGTH), + ("client_user_agent", _MAX_METADATA_LENGTH), + ("vendor_client", _MAX_METADATA_LENGTH), ) _NORMALIZED_FIELDS = ("parameters", "response", "identify_actor_data", "error") diff --git a/posthog/mcp/constants.py b/posthog/mcp/constants.py index 3172eb1b6..427735e30 100644 --- a/posthog/mcp/constants.py +++ b/posthog/mcp/constants.py @@ -56,7 +56,9 @@ class PostHogMCPAnalyticsProperty: """PostHog property wire-keys emitted on MCP events.""" CLIENT_NAME = "$mcp_client_name" + CLIENT_USER_AGENT = "$mcp_client_user_agent" CLIENT_VERSION = "$mcp_client_version" + VENDOR_CLIENT = "$mcp_vendor_client" PROTOCOL_VERSION = "$mcp_protocol_version" CONVERSATION_ID = "$mcp_conversation_id" DURATION_MS = "$mcp_duration_ms" diff --git a/posthog/mcp/posthog_mcp.py b/posthog/mcp/posthog_mcp.py index 75f10b2f5..a616a88be 100644 --- a/posthog/mcp/posthog_mcp.py +++ b/posthog/mcp/posthog_mcp.py @@ -93,6 +93,8 @@ def capture_tool_call( protocol_version: Optional[str] = None, distinct_id: Optional[str] = None, session_id: Optional[str] = None, + client_user_agent: Optional[str] = None, + vendor_client: Optional[str] = None, set_properties: Optional[JsonRecord] = None, groups: Optional[Dict[str, str]] = None, properties: Optional[JsonRecord] = None, @@ -107,6 +109,8 @@ def capture_tool_call( groups, properties, timestamp, + client_user_agent, + vendor_client, ) event["resource_name"] = tool_name event["tool_description"] = tool_description @@ -135,6 +139,8 @@ def capture_initialize( duration_ms: Optional[float] = None, distinct_id: Optional[str] = None, session_id: Optional[str] = None, + client_user_agent: Optional[str] = None, + vendor_client: Optional[str] = None, set_properties: Optional[JsonRecord] = None, groups: Optional[Dict[str, str]] = None, properties: Optional[JsonRecord] = None, @@ -149,6 +155,8 @@ def capture_initialize( groups, properties, timestamp, + client_user_agent, + vendor_client, ) event["client_name"] = client_name event["client_version"] = client_version @@ -171,6 +179,8 @@ def capture_tools_list( protocol_version: Optional[str] = None, distinct_id: Optional[str] = None, session_id: Optional[str] = None, + client_user_agent: Optional[str] = None, + vendor_client: Optional[str] = None, set_properties: Optional[JsonRecord] = None, groups: Optional[Dict[str, str]] = None, properties: Optional[JsonRecord] = None, @@ -186,6 +196,8 @@ def capture_tools_list( groups, properties, timestamp, + client_user_agent, + vendor_client, ) event["listed_tool_names"] = tool_names event["protocol_version"] = protocol_version @@ -208,6 +220,8 @@ def capture_missing_capability( protocol_version: Optional[str] = None, distinct_id: Optional[str] = None, session_id: Optional[str] = None, + client_user_agent: Optional[str] = None, + vendor_client: Optional[str] = None, set_properties: Optional[JsonRecord] = None, groups: Optional[Dict[str, str]] = None, properties: Optional[JsonRecord] = None, @@ -223,6 +237,8 @@ def capture_missing_capability( groups, properties, timestamp, + client_user_agent, + vendor_client, ) event["resource_name"] = self._missing_capability_tool_name event["protocol_version"] = protocol_version @@ -285,6 +301,8 @@ def _base_event( groups: Optional[Dict[str, str]], properties: Optional[JsonRecord], timestamp: Optional[datetime], + client_user_agent: Optional[str] = None, + vendor_client: Optional[str] = None, ) -> Dict[str, Any]: event: Dict[str, Any] = { "event_type": event_type, @@ -292,6 +310,11 @@ def _base_event( "timestamp": timestamp or datetime.now(timezone.utc), "properties": properties, "groups": groups, + # Raw transport headers. A custom dispatcher holds its own request + # object, so it passes these itself; instrumented servers read them + # off the request automatically. + "client_user_agent": client_user_agent, + "vendor_client": vendor_client, } if distinct_id: event["identify_actor_given_id"] = distinct_id diff --git a/posthog/test/mcp/test_transport_identity.py b/posthog/test/mcp/test_transport_identity.py new file mode 100644 index 000000000..e5049616d --- /dev/null +++ b/posthog/test/mcp/test_transport_identity.py @@ -0,0 +1,162 @@ +"""``$mcp_client_user_agent`` / ``$mcp_vendor_client`` — which *product* called. + +``clientInfo.name`` only says which client library is calling: Anthropic reports +``claude-code`` from the CLI, the Agent SDK, the VS Code extension and the +desktop app alike, so `$mcp_client_name` collapses every surface into one +bucket and the harness breakdown reads 100% "Other". The distinguishing detail +lives in the User-Agent parenthetical and vendor headers. Captured raw and +classified at query time. Parity with ``@posthog/mcp``. +Runs under both MCP SDK majors. +""" + +from types import SimpleNamespace + +from posthog.mcp import PostHogMCP +from posthog.mcp._transport_identity import stamp_transport_identity +from posthog.mcp.constants import PostHogMCPAnalyticsProperty as P +from posthog.test.mcp._helpers import ( + MCP_MAJOR, + FakeClient, + events_named as _events, + flush_background as _flush, +) + +UA = "claude-code/2.1.0 (cli)" + + +def _extra(headers): + return { + "session_id": None, + "ctx": SimpleNamespace(request=SimpleNamespace(headers=headers)), + } + + +# --- reading the headers -------------------------------------------------------- + + +def test_stamps_both_headers_onto_the_event(): + event = {} + stamp_transport_identity( + event, _extra({"user-agent": UA, "x-anthropic-client": "desktop"}) + ) + + assert event["client_user_agent"] == UA + assert event["vendor_client"] == "desktop" + + +def test_header_case_does_not_matter(): + event = {} + stamp_transport_identity(event, _extra({"User-Agent": UA})) + + assert event["client_user_agent"] == UA + + +def test_each_header_is_independent(): + """Captured as two separate signals, not merged, so a query-time resolver + can prefer whichever the vendor keeps stable.""" + ua_only, vendor_only = {}, {} + stamp_transport_identity(ua_only, _extra({"user-agent": UA})) + stamp_transport_identity(vendor_only, _extra({"x-anthropic-client": "desktop"})) + + assert ua_only == {"client_user_agent": UA} + assert vendor_only == {"vendor_client": "desktop"} + + +def test_stdio_and_header_less_transports_stamp_nothing(): + for extra in ( + {"session_id": None, "ctx": SimpleNamespace(request=None)}, # stdio + {"session_id": None}, # no ctx at all + None, + ): + event = {} + stamp_transport_identity(event, extra) + assert event == {} + + +def test_a_hostile_header_object_cannot_break_a_tool_call(): + class Exploding: + def items(self): + raise RuntimeError("nope") + + event = {} + stamp_transport_identity(event, _extra(Exploding())) + assert event == {} + + +# --- end to end ----------------------------------------------------------------- + + +async def test_instrumented_server_captures_the_surface(): + if MCP_MAJOR >= 2: + from mcp.server.mcpserver import MCPServer as Server + else: + from mcp.server.fastmcp import FastMCP as Server + + from posthog.mcp import instrument + + server = Server("ua-e2e") + + @server.tool() + def echo(msg: str) -> str: + return msg + + client = FakeClient() + instrument(server, client) + + context = SimpleNamespace( + request_context=SimpleNamespace( + request=SimpleNamespace( + headers={"user-agent": UA, "x-anthropic-client": "cli"} + ), + session=SimpleNamespace(client_params=None), + ) + ) + await server._tool_manager.call_tool( + "echo", {"msg": "hi", "context": "surface attribution"}, context=context + ) + await _flush() + + props = _events(client, "$mcp_tool_call")[0]["properties"] + assert props[P.CLIENT_USER_AGENT] == UA + assert props[P.VENDOR_CLIENT] == "cli" + + +async def test_custom_dispatchers_can_pass_their_own(): + """A hand-rolled dispatcher holds its own request object, so it passes the + headers explicitly rather than us digging them out.""" + captured = [] + client = PostHogMCP("phc_test") + client.capture = lambda event, **kw: captured.append({"event": event, **kw}) + + client.capture_tool_call("add", client_user_agent=UA, vendor_client="desktop") + await _flush() + + props = _events(captured, "$mcp_tool_call")[0]["properties"] + assert props[P.CLIENT_USER_AGENT] == UA + assert props[P.VENDOR_CLIENT] == "desktop" + + +async def test_absent_headers_leave_events_byte_identical(): + """stdio servers must emit exactly what they emitted before this feature.""" + captured = [] + client = PostHogMCP("phc_test") + client.capture = lambda event, **kw: captured.append({"event": event, **kw}) + + client.capture_tool_call("add") + await _flush() + + props = _events(captured, "$mcp_tool_call")[0]["properties"] + assert P.CLIENT_USER_AGENT not in props + assert P.VENDOR_CLIENT not in props + + +async def test_a_huge_header_cannot_inflate_the_event(): + captured = [] + client = PostHogMCP("phc_test") + client.capture = lambda event, **kw: captured.append({"event": event, **kw}) + + client.capture_tool_call("add", client_user_agent="x" * 10_000) + await _flush() + + props = _events(captured, "$mcp_tool_call")[0]["properties"] + assert len(props[P.CLIENT_USER_AGENT]) < 1000 diff --git a/references/public_api_snapshot.txt b/references/public_api_snapshot.txt index 707678c9d..06009428a 100644 --- a/references/public_api_snapshot.txt +++ b/references/public_api_snapshot.txt @@ -711,6 +711,7 @@ attribute posthog.mcp.constants.PostHogMCPAnalyticsEvent.RESOURCE_READ = '$mcp_r attribute posthog.mcp.constants.PostHogMCPAnalyticsEvent.TOOLS_LIST = '$mcp_tools_list' attribute posthog.mcp.constants.PostHogMCPAnalyticsEvent.TOOL_CALL = '$mcp_tool_call' attribute posthog.mcp.constants.PostHogMCPAnalyticsProperty.CLIENT_NAME = '$mcp_client_name' +attribute posthog.mcp.constants.PostHogMCPAnalyticsProperty.CLIENT_USER_AGENT = '$mcp_client_user_agent' attribute posthog.mcp.constants.PostHogMCPAnalyticsProperty.CLIENT_VERSION = '$mcp_client_version' attribute posthog.mcp.constants.PostHogMCPAnalyticsProperty.CONVERSATION_ID = '$mcp_conversation_id' attribute posthog.mcp.constants.PostHogMCPAnalyticsProperty.DURATION_MS = '$mcp_duration_ms' @@ -731,6 +732,7 @@ attribute posthog.mcp.constants.PostHogMCPAnalyticsProperty.SOURCE = '$mcp_sourc attribute posthog.mcp.constants.PostHogMCPAnalyticsProperty.TOOL_CATEGORY = '$mcp_tool_category' attribute posthog.mcp.constants.PostHogMCPAnalyticsProperty.TOOL_DESCRIPTION = '$mcp_tool_description' attribute posthog.mcp.constants.PostHogMCPAnalyticsProperty.TOOL_NAME = '$mcp_tool_name' +attribute posthog.mcp.constants.PostHogMCPAnalyticsProperty.VENDOR_CLIENT = '$mcp_vendor_client' attribute posthog.mcp.session_token.MCP_SESSION_HEADER = 'mcp-session-id' attribute posthog.mcp.session_token.SessionTokenPayload.client_name: Optional[str] = None attribute posthog.mcp.session_token.SessionTokenPayload.client_version: Optional[str] = None @@ -1332,10 +1334,10 @@ method posthog.integrations.django.PosthogContextMiddleware.extract_tags(request method posthog.integrations.django.PosthogContextMiddleware.process_exception(request, exception) method posthog.mcp.McpAnalytics.capture(event: str, properties: Optional[dict] = None) -> None method posthog.mcp.McpAnalytics.flush() -> None -method posthog.mcp.posthog_mcp.PostHogMCP.capture_initialize(*, client_name: Optional[str] = None, client_version: Optional[str] = None, protocol_version: Optional[str] = None, parameters: Any = None, response: Any = None, duration_ms: Optional[float] = None, distinct_id: Optional[str] = None, session_id: Optional[str] = None, set_properties: Optional[JsonRecord] = None, groups: Optional[Dict[str, str]] = None, properties: Optional[JsonRecord] = None, timestamp: Optional[datetime] = None) -> None -method posthog.mcp.posthog_mcp.PostHogMCP.capture_missing_capability(*, context: Optional[str] = None, parameters: Any = None, protocol_version: Optional[str] = None, distinct_id: Optional[str] = None, session_id: Optional[str] = None, set_properties: Optional[JsonRecord] = None, groups: Optional[Dict[str, str]] = None, properties: Optional[JsonRecord] = None, timestamp: Optional[datetime] = None) -> None -method posthog.mcp.posthog_mcp.PostHogMCP.capture_tool_call(tool_name: str, *, intent: Optional[str] = None, intent_source: Optional[str] = None, parameters: Any = None, response: Any = None, duration_ms: Optional[float] = None, is_error: bool = False, error: Any = None, error_type: Optional[str] = None, category: Optional[str] = None, tool_description: Optional[str] = None, protocol_version: Optional[str] = None, distinct_id: Optional[str] = None, session_id: Optional[str] = None, set_properties: Optional[JsonRecord] = None, groups: Optional[Dict[str, str]] = None, properties: Optional[JsonRecord] = None, timestamp: Optional[datetime] = None) -> None -method posthog.mcp.posthog_mcp.PostHogMCP.capture_tools_list(*, tool_names: Optional[List[str]] = None, parameters: Any = None, response: Any = None, duration_ms: Optional[float] = None, is_error: bool = False, error: Any = None, error_type: Optional[str] = None, protocol_version: Optional[str] = None, distinct_id: Optional[str] = None, session_id: Optional[str] = None, set_properties: Optional[JsonRecord] = None, groups: Optional[Dict[str, str]] = None, properties: Optional[JsonRecord] = None, timestamp: Optional[datetime] = None) -> None +method posthog.mcp.posthog_mcp.PostHogMCP.capture_initialize(*, client_name: Optional[str] = None, client_version: Optional[str] = None, protocol_version: Optional[str] = None, parameters: Any = None, response: Any = None, duration_ms: Optional[float] = None, distinct_id: Optional[str] = None, session_id: Optional[str] = None, client_user_agent: Optional[str] = None, vendor_client: Optional[str] = None, set_properties: Optional[JsonRecord] = None, groups: Optional[Dict[str, str]] = None, properties: Optional[JsonRecord] = None, timestamp: Optional[datetime] = None) -> None +method posthog.mcp.posthog_mcp.PostHogMCP.capture_missing_capability(*, context: Optional[str] = None, parameters: Any = None, protocol_version: Optional[str] = None, distinct_id: Optional[str] = None, session_id: Optional[str] = None, client_user_agent: Optional[str] = None, vendor_client: Optional[str] = None, set_properties: Optional[JsonRecord] = None, groups: Optional[Dict[str, str]] = None, properties: Optional[JsonRecord] = None, timestamp: Optional[datetime] = None) -> None +method posthog.mcp.posthog_mcp.PostHogMCP.capture_tool_call(tool_name: str, *, intent: Optional[str] = None, intent_source: Optional[str] = None, parameters: Any = None, response: Any = None, duration_ms: Optional[float] = None, is_error: bool = False, error: Any = None, error_type: Optional[str] = None, category: Optional[str] = None, tool_description: Optional[str] = None, protocol_version: Optional[str] = None, distinct_id: Optional[str] = None, session_id: Optional[str] = None, client_user_agent: Optional[str] = None, vendor_client: Optional[str] = None, set_properties: Optional[JsonRecord] = None, groups: Optional[Dict[str, str]] = None, properties: Optional[JsonRecord] = None, timestamp: Optional[datetime] = None) -> None +method posthog.mcp.posthog_mcp.PostHogMCP.capture_tools_list(*, tool_names: Optional[List[str]] = None, parameters: Any = None, response: Any = None, duration_ms: Optional[float] = None, is_error: bool = False, error: Any = None, error_type: Optional[str] = None, protocol_version: Optional[str] = None, distinct_id: Optional[str] = None, session_id: Optional[str] = None, client_user_agent: Optional[str] = None, vendor_client: Optional[str] = None, set_properties: Optional[JsonRecord] = None, groups: Optional[Dict[str, str]] = None, properties: Optional[JsonRecord] = None, timestamp: Optional[datetime] = None) -> None method posthog.mcp.posthog_mcp.PostHogMCP.flush(timeout_seconds: Optional[float] = 10) -> None method posthog.mcp.posthog_mcp.PostHogMCP.prepare_tool_call(name: str, args: Optional[JsonRecord] = None) -> PreparedToolCall method posthog.mcp.posthog_mcp.PostHogMCP.prepare_tool_list(tools: List[Any], context: Union[bool, MCPAnalyticsContextOptions] = True, report_missing: bool = False) -> List[Any] From 9825917a39c6b7fa5361bb526d19a350ef529e09 Mon Sep 17 00:00:00 2001 From: Georgis Andonis Date: Fri, 21 Aug 2026 12:24:51 +0300 Subject: [PATCH 2/2] test(mcp): prove header capture over real HTTP transports, both majors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The unit tests drove a hand-built headers mapping, which proves the function works but not the feature: the whole point is reading headers off a real request, and "the User-Agent never showed up" is the production symptom this closes. Both new tests send a real User-Agent and X-Anthropic-Client through an actual streamable-HTTP app and assert the properties land on the captured event: - v2: through the existing dual-era httpx/ASGITransport harness - v1: through a real FastMCP app via Starlette's TestClient, reusing the pattern in test_session_token.py — v1 is where every MCP client today still lives, so it is the lane that most needs the real-transport proof Both exercise Starlette's own Headers object rather than a dict, which is the shape get_request_headers actually meets in production. Generated-By: PostHog Code Task-Id: ebafcb71-b03b-443d-b40c-d527ed4a04f4 --- posthog/test/mcp/test_transport_identity.py | 62 +++++++++++++++++++++ posthog/test/mcp/test_v2_wire_dual_era.py | 38 +++++++++++++ 2 files changed, 100 insertions(+) diff --git a/posthog/test/mcp/test_transport_identity.py b/posthog/test/mcp/test_transport_identity.py index e5049616d..1ce07a8f1 100644 --- a/posthog/test/mcp/test_transport_identity.py +++ b/posthog/test/mcp/test_transport_identity.py @@ -160,3 +160,65 @@ async def test_a_huge_header_cannot_inflate_the_event(): props = _events(captured, "$mcp_tool_call")[0]["properties"] assert len(props[P.CLIENT_USER_AGENT]) < 1000 + + +async def test_real_v1_request_headers_reach_the_event(): + """Surface attribution over a *real* v1 HTTP request. + + The unit tests above drive a hand-built mapping; this drives Starlette's own + ``Headers`` object through a real FastMCP streamable-HTTP app — which is + where the feature has to work, and where every MCP client today still lives. + """ + import pytest + + pytest.importorskip("starlette.testclient") + pytest.importorskip("mcp.server.fastmcp") # v1-only server; skipped under mcp>=2 + from mcp.server.fastmcp import FastMCP + from mcp.server.transport_security import TransportSecuritySettings + from starlette.testclient import TestClient + + from posthog.mcp import instrument + + server = FastMCP( + "ua-wire-v1", + stateless_http=True, + json_response=True, + # TestClient sends Host: testserver; allow it past DNS-rebinding protection. + transport_security=TransportSecuritySettings( + enable_dns_rebinding_protection=False + ), + ) + + @server.tool() + def add(a: int, b: int) -> int: + return a + b + + client = FakeClient() + instrument(server, client) + + with TestClient(server.streamable_http_app()) as http: + http.post( + "/mcp", + headers={ + "Content-Type": "application/json", + "Accept": "application/json, text/event-stream", + "User-Agent": UA, + "X-Anthropic-Client": "cli", + }, + json={ + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": { + "name": "add", + "arguments": {"a": 1, "b": 2, "context": "real v1 header check"}, + }, + }, + ) + await _flush() + + calls = _events(client, "$mcp_tool_call") + assert calls, "the tool call was not captured at all" + props = calls[0]["properties"] + assert props[P.CLIENT_USER_AGENT] == UA + assert props[P.VENDOR_CLIENT] == "cli" diff --git a/posthog/test/mcp/test_v2_wire_dual_era.py b/posthog/test/mcp/test_v2_wire_dual_era.py index a29334ae3..8372bcbc6 100644 --- a/posthog/test/mcp/test_v2_wire_dual_era.py +++ b/posthog/test/mcp/test_v2_wire_dual_era.py @@ -344,3 +344,41 @@ async def test_modern_rejects_initialize_but_analytics_stays_out_of_it(): # whatever the SDK answers (error payload), the app must not 500 assert response.status_code < 500 + + +async def test_real_request_headers_reach_the_event(): + """Surface attribution over a *real* HTTP request, not a synthetic context. + + The unit tests drive a hand-built headers mapping; this drives Starlette's + own `Headers` object through the actual transport, which is where the + feature has to work — a Python server reporting 100% "Other" in the harness + breakdown is the symptom this closes. + """ + from posthog.mcp.constants import PostHogMCPAnalyticsProperty as P + + server = make_server() + client = FakeClient() + instrument(server, client) + + user_agent = "claude-code/2.1.0 (cli)" + async with wire(server) as http: + body = rpc( + "tools/call", + { + "name": "add", + "arguments": {"a": 1, "b": 2, "context": "real header check"}, + "_meta": modern_meta(), + }, + ) + headers = { + **modern_headers("tools/call", "add"), + "user-agent": user_agent, + "x-anthropic-client": "cli", + } + response = await http.post("/mcp", json=body, headers=headers) + await _flush() + + assert response.status_code == 200 + props = _events(client, "$mcp_tool_call")[0]["properties"] + assert props[P.CLIENT_USER_AGENT] == user_agent + assert props[P.VENDOR_CLIENT] == "cli"