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..1ce07a8f1 --- /dev/null +++ b/posthog/test/mcp/test_transport_identity.py @@ -0,0 +1,224 @@ +"""``$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 + + +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" 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]