Skip to content

Commit f483bab

Browse files
authored
feat(mcp): capture $mcp_client_user_agent and $mcp_vendor_client (#883)
* feat(mcp): capture $mcp_client_user_agent and $mcp_vendor_client 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 * test(mcp): prove header capture over real HTTP transports, both majors 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
1 parent acdaae2 commit f483bab

12 files changed

Lines changed: 403 additions & 5 deletions
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
posthog: minor
3+
---
4+
5+
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`.

‎posthog/mcp/_capture.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,8 @@ def capture_event(
5050
"client_name": event_input.get("client_name"),
5151
"client_version": event_input.get("client_version"),
5252
"protocol_version": event_input.get("protocol_version"),
53+
"client_user_agent": event_input.get("client_user_agent"),
54+
"vendor_client": event_input.get("vendor_client"),
5355
"identify_actor_given_id": actor.distinct_id if actor else None,
5456
"identify_actor_data": (actor.properties or {}) if actor else {},
5557
"groups": actor.groups if actor else None,

‎posthog/mcp/_instrument_v2.py‎

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,20 @@ def add_request_handler(method: str, params_type: Any, handler: Any) -> None:
151151
# --- ctx readers -----------------------------------------------------------------
152152

153153

154+
def _request_context_of(context: Any) -> Any:
155+
"""The request context behind a v2 ``Context``, or ``None``.
156+
157+
Reads the public property rather than the private ``_request_context`` it
158+
wraps, guarded because it *raises* outside a request (the same trap the
159+
FastMCP adapter hit). Falls back to the private attribute so a stand-in
160+
object that only carries that still works.
161+
"""
162+
try:
163+
return context.request_context
164+
except (LookupError, ValueError, AttributeError):
165+
return getattr(context, "_request_context", None)
166+
167+
154168
def _ctx_client_info(ctx: Any) -> Tuple[Optional[str], Optional[str]]:
155169
try:
156170
client_params = ctx.session.client_params
@@ -243,7 +257,7 @@ async def wrapped(
243257
context: Any = None,
244258
convert_result: bool = False,
245259
) -> Any:
246-
ctx = getattr(context, "_request_context", None)
260+
ctx = _request_context_of(context)
247261
token, client_name, client_version, protocol_version, mcp_session_id = (
248262
_resolve_ctx(ctx)
249263
)

‎posthog/mcp/_instrumentation.py‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
from ._internal import MCPAnalyticsData, handle_identify, resolve_event_properties
2424
from .logger import log
2525
from ._sanitization import build_captured_mcp_parameters
26+
from ._transport_identity import stamp_transport_identity
2627
from .session import resolve_session_id
2728
from .session_token import SessionTokenPayload, decode_session_id
2829

@@ -211,6 +212,7 @@ async def _maybe_emit_initialize(
211212
await _apply_event_properties(
212213
data, event, {"method": "initialize", "params": {}}, extra
213214
)
215+
stamp_transport_identity(event, extra)
214216
fire_and_forget(capture_event(data, event), data)
215217

216218

@@ -364,6 +366,7 @@ async def record_tool_call(
364366
if props is not None:
365367
event["properties"] = props
366368

369+
stamp_transport_identity(event, extra)
367370
fire_and_forget(capture_event(data, event), data)
368371
except Exception as err: # noqa: BLE001 - isolate analytics from the tool path
369372
log(f"record_tool_call failed (event dropped, tool unaffected): {err}")
@@ -458,6 +461,7 @@ async def record_missing_capability(
458461
event["user_intent"] = context.strip()
459462
event["user_intent_source"] = "context_parameter"
460463
await _apply_event_properties(data, event, request, extra)
464+
stamp_transport_identity(event, extra)
461465
fire_and_forget(capture_event(data, event), data)
462466
except Exception as err: # noqa: BLE001 - isolate analytics from the tool path
463467
log(f"record_missing_capability failed (event dropped): {err}")
@@ -495,6 +499,7 @@ async def record_tools_list(
495499
if error is not None:
496500
event["error"] = capture_exception(error)
497501
await _apply_event_properties(data, event, request, extra)
502+
stamp_transport_identity(event, extra)
498503
fire_and_forget(capture_event(data, event), data)
499504
except Exception as err: # noqa: BLE001 - isolate analytics from the tool path
500505
log(f"record_tools_list failed (event dropped): {err}")

‎posthog/mcp/_posthog_events.py‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,12 @@ def _add_common_properties(event: Event, properties: Dict[str, Any]) -> None:
131131
properties[_P.CLIENT_NAME] = event["client_name"]
132132
if event.get("client_version"):
133133
properties[_P.CLIENT_VERSION] = event["client_version"]
134+
# HTTP transports only, and only for the request that carried the header —
135+
# stdio and in-memory servers simply never set these.
136+
if event.get("client_user_agent"):
137+
properties[_P.CLIENT_USER_AGENT] = event["client_user_agent"]
138+
if event.get("vendor_client"):
139+
properties[_P.VENDOR_CLIENT] = event["vendor_client"]
134140
if event.get("protocol_version"):
135141
properties[_P.PROTOCOL_VERSION] = event["protocol_version"]
136142
if event.get("user_intent"):

‎posthog/mcp/_transport_identity.py‎

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
# Portions of this package are derived from MCPCat/mcpcat-typescript-sdk
2+
# Copyright (c) 2025 MCPcat
3+
# Licensed under the MIT License: https://github.com/MCPCat/mcpcat-typescript-sdk/blob/main/LICENSE
4+
5+
"""Transport-level client identity: the two request headers that say *which
6+
product* is calling, where ``clientInfo`` only says which client library is.
7+
8+
MCP's own identity fields are too coarse to attribute usage to a surface. A
9+
vendor ships many products on one client: Anthropic reports
10+
``clientInfo.name = "claude-code"`` from the CLI, the Agent SDK, the VS Code
11+
extension and the desktop app alike, so ``$mcp_client_name`` collapses all of
12+
them into one bucket. The distinguishing detail lives in the User-Agent
13+
parenthetical — ``claude-code/2.1.0 (cli)`` vs ``(sdk-ts)`` vs
14+
``(claude-vscode)`` — and in vendor headers like ``x-anthropic-client``.
15+
Capturing them is the only way a server owner can tell their surfaces apart.
16+
17+
We capture the raw strings and classify nothing. No vendor table, no product
18+
labels: friendly names are resolved at query time server-side, so labels can
19+
improve (and new surfaces appear) without waiting on an SDK release.
20+
21+
Deliberately separate from the client identity read out of the request body's
22+
``_meta``, which works on every transport. Headers exist only on HTTP
23+
transports, so everything here is a silent no-op on stdio and in-memory servers
24+
and their events stay byte-identical to before.
25+
"""
26+
27+
from __future__ import annotations
28+
29+
from typing import Any, Dict
30+
31+
from .request_headers import get_request_headers
32+
33+
__all__ = [
34+
"CLIENT_USER_AGENT_HEADER",
35+
"VENDOR_CLIENT_HEADER",
36+
"stamp_transport_identity",
37+
]
38+
39+
#: Header carrying the client's product/surface, e.g. ``claude-code/2.1.0 (cli)``.
40+
CLIENT_USER_AGENT_HEADER = "user-agent"
41+
42+
#: Vendor-specific client header. Anthropic's clients send it alongside the
43+
#: User-Agent; captured verbatim as a second, independent signal rather than
44+
#: merged into one, so a query-time resolver can prefer whichever the vendor
45+
#: keeps stable.
46+
VENDOR_CLIENT_HEADER = "x-anthropic-client"
47+
48+
49+
def stamp_transport_identity(event: Dict[str, Any], extra: Any) -> None:
50+
"""Stamp the transport identity onto the event being built for *this*
51+
request, so it carries ``$mcp_client_user_agent`` and ``$mcp_vendor_client``.
52+
53+
Headers are per-request, so this writes to the event — a per-request object
54+
— and never to server-wide state. One instrumented server multiplexes
55+
concurrent requests from different clients, and caching a header into shared
56+
state would attribute one client's surface to another's event.
57+
58+
Values are capped downstream by truncation, which runs on every capture
59+
path, so a hostile 1MB header cannot inflate an event. Never raises: a
60+
header read must not take a tool call down with it.
61+
"""
62+
try:
63+
headers = get_request_headers(extra)
64+
except Exception: # noqa: BLE001 - defensive; get_request_headers is already guarded
65+
return
66+
if not headers:
67+
return
68+
69+
# get_request_headers lowercases keys, so a direct lookup is enough.
70+
user_agent = headers.get(CLIENT_USER_AGENT_HEADER)
71+
if user_agent:
72+
event["client_user_agent"] = user_agent
73+
vendor_client = headers.get(VENDOR_CLIENT_HEADER)
74+
if vendor_client:
75+
event["vendor_client"] = vendor_client

‎posthog/mcp/_truncation.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,8 @@
4242
("client_name", _MAX_METADATA_LENGTH),
4343
("client_version", _MAX_METADATA_LENGTH),
4444
("error_type", _MAX_METADATA_LENGTH),
45+
("client_user_agent", _MAX_METADATA_LENGTH),
46+
("vendor_client", _MAX_METADATA_LENGTH),
4547
)
4648

4749
_NORMALIZED_FIELDS = ("parameters", "response", "identify_actor_data", "error")

‎posthog/mcp/constants.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,9 @@ class PostHogMCPAnalyticsProperty:
5656
"""PostHog property wire-keys emitted on MCP events."""
5757

5858
CLIENT_NAME = "$mcp_client_name"
59+
CLIENT_USER_AGENT = "$mcp_client_user_agent"
5960
CLIENT_VERSION = "$mcp_client_version"
61+
VENDOR_CLIENT = "$mcp_vendor_client"
6062
PROTOCOL_VERSION = "$mcp_protocol_version"
6163
CONVERSATION_ID = "$mcp_conversation_id"
6264
DURATION_MS = "$mcp_duration_ms"

‎posthog/mcp/posthog_mcp.py‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,8 @@ def capture_tool_call(
9393
protocol_version: Optional[str] = None,
9494
distinct_id: Optional[str] = None,
9595
session_id: Optional[str] = None,
96+
client_user_agent: Optional[str] = None,
97+
vendor_client: Optional[str] = None,
9698
set_properties: Optional[JsonRecord] = None,
9799
groups: Optional[Dict[str, str]] = None,
98100
properties: Optional[JsonRecord] = None,
@@ -107,6 +109,8 @@ def capture_tool_call(
107109
groups,
108110
properties,
109111
timestamp,
112+
client_user_agent,
113+
vendor_client,
110114
)
111115
event["resource_name"] = tool_name
112116
event["tool_description"] = tool_description
@@ -135,6 +139,8 @@ def capture_initialize(
135139
duration_ms: Optional[float] = None,
136140
distinct_id: Optional[str] = None,
137141
session_id: Optional[str] = None,
142+
client_user_agent: Optional[str] = None,
143+
vendor_client: Optional[str] = None,
138144
set_properties: Optional[JsonRecord] = None,
139145
groups: Optional[Dict[str, str]] = None,
140146
properties: Optional[JsonRecord] = None,
@@ -149,6 +155,8 @@ def capture_initialize(
149155
groups,
150156
properties,
151157
timestamp,
158+
client_user_agent,
159+
vendor_client,
152160
)
153161
event["client_name"] = client_name
154162
event["client_version"] = client_version
@@ -171,6 +179,8 @@ def capture_tools_list(
171179
protocol_version: Optional[str] = None,
172180
distinct_id: Optional[str] = None,
173181
session_id: Optional[str] = None,
182+
client_user_agent: Optional[str] = None,
183+
vendor_client: Optional[str] = None,
174184
set_properties: Optional[JsonRecord] = None,
175185
groups: Optional[Dict[str, str]] = None,
176186
properties: Optional[JsonRecord] = None,
@@ -186,6 +196,8 @@ def capture_tools_list(
186196
groups,
187197
properties,
188198
timestamp,
199+
client_user_agent,
200+
vendor_client,
189201
)
190202
event["listed_tool_names"] = tool_names
191203
event["protocol_version"] = protocol_version
@@ -208,6 +220,8 @@ def capture_missing_capability(
208220
protocol_version: Optional[str] = None,
209221
distinct_id: Optional[str] = None,
210222
session_id: Optional[str] = None,
223+
client_user_agent: Optional[str] = None,
224+
vendor_client: Optional[str] = None,
211225
set_properties: Optional[JsonRecord] = None,
212226
groups: Optional[Dict[str, str]] = None,
213227
properties: Optional[JsonRecord] = None,
@@ -223,6 +237,8 @@ def capture_missing_capability(
223237
groups,
224238
properties,
225239
timestamp,
240+
client_user_agent,
241+
vendor_client,
226242
)
227243
event["resource_name"] = self._missing_capability_tool_name
228244
event["protocol_version"] = protocol_version
@@ -285,13 +301,20 @@ def _base_event(
285301
groups: Optional[Dict[str, str]],
286302
properties: Optional[JsonRecord],
287303
timestamp: Optional[datetime],
304+
client_user_agent: Optional[str] = None,
305+
vendor_client: Optional[str] = None,
288306
) -> Dict[str, Any]:
289307
event: Dict[str, Any] = {
290308
"event_type": event_type,
291309
"session_id": session_id,
292310
"timestamp": timestamp or datetime.now(timezone.utc),
293311
"properties": properties,
294312
"groups": groups,
313+
# Raw transport headers. A custom dispatcher holds its own request
314+
# object, so it passes these itself; instrumented servers read them
315+
# off the request automatically.
316+
"client_user_agent": client_user_agent,
317+
"vendor_client": vendor_client,
295318
}
296319
if distinct_id:
297320
event["identify_actor_given_id"] = distinct_id

0 commit comments

Comments
 (0)