Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .sampo/changesets/mcp-client-attribution.md
Original file line number Diff line number Diff line change
@@ -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`.
2 changes: 2 additions & 0 deletions posthog/mcp/_capture.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
16 changes: 15 additions & 1 deletion posthog/mcp/_instrument_v2.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
)
Expand Down
5 changes: 5 additions & 0 deletions posthog/mcp/_instrumentation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)


Expand Down Expand Up @@ -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}")
Expand Down Expand Up @@ -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}")
Expand Down Expand Up @@ -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}")
6 changes: 6 additions & 0 deletions posthog/mcp/_posthog_events.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"):
Expand Down
75 changes: 75 additions & 0 deletions posthog/mcp/_transport_identity.py
Original file line number Diff line number Diff line change
@@ -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
2 changes: 2 additions & 0 deletions posthog/mcp/_truncation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
2 changes: 2 additions & 0 deletions posthog/mcp/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
23 changes: 23 additions & 0 deletions posthog/mcp/posthog_mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand All @@ -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,
Expand All @@ -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
Expand All @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -285,13 +301,20 @@ 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,
"session_id": session_id,
"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
Expand Down
Loading