Instrumenting a custom server
Contents
instrument() works by wrapping a @modelcontextprotocol/sdk Server or McpServer — it patches that object's request handlers. But not every MCP server is built that way. If you run a custom dispatcher — a Hono or Express HTTP handler, a Cloudflare Worker / Vercel edge function, or anything that speaks the MCP protocol without the SDK's server abstraction — there's no object for instrument() to wrap.
For those servers, use PostHogMCP instead. It's a subclass of the posthog-node client, so it's a drop-in replacement for your existing PostHog client. It adds preparation helpers for tool schemas and calls, plus capture methods for tool calls, tool listings, initialization, and missing capabilities. You resolve request metadata and call the matching methods yourself. They build the same canonical $mcp_* events as instrument() and use the same sanitization, truncation, and $exception fan-out.
When to use which
| Your server | Use |
|---|---|
Built on @modelcontextprotocol/sdk's Server / McpServer | instrument(server, posthog, options?) |
| A custom HTTP/Hono/edge dispatcher with no server object to wrap | new PostHogMCP(apiKey, options?) |
The examples below are TypeScript. Python has the same helper with the same methods in snake_case — skip to Python.
Set up
PostHogMCP takes the exact same constructor arguments as posthog-node's PostHog, so swap the class and you keep one client for your whole app:
Because it is a PostHog client, every option and method you already know is available — including beforeSend (which runs on the MCP events too) and enableExceptionAutocapture (set it to false to stop errored tool calls from fanning out a $exception). The wrapping-path hooks (identify, context, intentFallback, eventProperties) don't apply here: there's no wrapped server to run them against, so you pass identity and properties on each call instead.
Capture events
Call the matching method from inside your dispatcher, after you've resolved who the user is and run the tool. The methods are fire-and-forget, just like posthog.capture():
Fields shared by every method
| Field | Maps to | Notes |
|---|---|---|
distinctId | distinct_id | Supplying it enables person processing so $set lands on a real person. Omit it for anonymous traffic — events are sent with $process_person_profile: false. |
sessionId | $session_id | Omitted from the event entirely when you don't pass one (so stateless captures don't bucket into a non-existent Session Replay session). |
protocolVersion | $mcp_protocol_version | Pass the revision from each request. The 2026-07-28 revision doesn't have an initialize request that can carry this state forward. |
clientUserAgent | $mcp_client_user_agent | Pass the raw User-Agent header on HTTP transports. |
vendorClient | $mcp_vendor_client | Pass the raw vendor client header, such as x-anthropic-client, when present. |
groups | $groups | { groupType: groupKey }, stamped on the event so you never hand-write the $groups key. |
setProperties | $set | Person properties ({ name, email, plan }), same as the properties you'd pass to identify. Updates the person profile; not retained on the stored event, so query them as person properties. |
properties | spread verbatim | Extra event properties, sitting alongside the $mcp_* keys. Values must be JSON-serializable. |
timestamp | event time | Defaults to the time of the capture call. |
Tool-call specific fields
toolName → $mcp_tool_name, toolDescription → $mcp_tool_description, parameters → $mcp_parameters, response → $mcp_response, durationMs → $mcp_duration_ms, isError → $mcp_is_error. When isError is true and enableExceptionAutocapture isn't false, the error you pass becomes the $exception sibling event (if you don't pass one, a generic exception is synthesized from the tool name).
captureToolCall and captureInitialize are fire-and-forget (they enqueue on the client, like posthog.capture()) and never throw — a failure to record analytics can't take down your tool. In serverless or edge environments, flush at the end of the invocation so queued events aren't dropped (see below).
What you don't get compared with instrument()
Because there's no wrapped server, PostHogMCP does not manage these for you — you pass the equivalent data per call:
- Sessions — no MCP-session-derived
$session_idor inactivity rollover. Pass your ownsessionId. - Identity caching /
$identifydedupe — passdistinctId(and optionalsetProperties) on each call. - Automatic intent and missing-capability handling — use
prepareToolList()andprepareToolCall(), then pass their output to the matching capture method. - Conversation IDs — pass your own stable
sessionId. The custom dispatcher helpers don't inject or echoconversation_id. - Model capture —
captureModeland the injectedllm_modelargument are currently available only throughinstrument()on a supported TypeScript server wrapper. Don't add$mcp_llm_modelmanually.
The 2026-07-28 revision has no initialize handshake or protocol session. Don't fabricate $mcp_initialize. Capture each request's protocolVersion, and pass an authenticated user id or your own stable session id when you need correlation across requests.
Everything from the event reference onward — event names, property shapes, sanitization, error tracking — is identical.
Graceful shutdown
PostHogMCP is a posthog-node client, so flush it yourself. In serverless or edge environments, flush at the end of each invocation rather than relying on SIGTERM:
Python
The Python SDK ships the same custom-dispatcher path as PostHogMCP, a subclass of the posthog client. Method names are snake_case and arguments are keyword args rather than an options object:
Capture the handshake and the tool listing the same way:
PostHogMCP(api_key, missing_capability_tool_name="get_more_tools", mcp_exception_autocapture=True, **posthog_kwargs) accepts the standard posthog client kwargs — host, and before_send if you need to drop or rewrite payloads. Set mcp_exception_autocapture=False to stop a failed tool call from emitting a $exception sibling. As in TypeScript, the wrapping-path hooks (identify, context, intent_fallback, event_properties) don't apply here — pass identity and properties on each capture_* call.
Failed calls
Pass error=exc with is_error=True and the SDK reads $mcp_error_message and $mcp_error_type off the exception, so the failures view shows why a call failed instead of an empty row. The message is sanitized and capped at 2048 characters. Add error_type="timeout" (or any low-cardinality label) to override the thrown class name with your own category. Requires posthog>=7.41; upgrade to 7.45.3 or later, which also unwraps the generic ToolError that mcp>=2.1 raises in place of your exception.
Attributing the caller
clientInfo.name reports claude-code from the CLI, the Agent SDK, the VS Code extension and the desktop app alike, so on its own it collapses every surface into one bucket and the harness breakdown reads mostly "Other". The distinguishing detail is in the raw transport headers, which a custom dispatcher has to pass itself (instrument() reads them off the request for you):
client_user_agent→$mcp_client_user_agent— the parenthetical carries the build (claude-code/2.1.0 (cli)vs(sdk-ts)).vendor_client→$mcp_vendor_client— from vendor headers likex-anthropic-client, the only thing that separates Anthropic's pooled surfaces (Claude.ai, Cowork, Claude Design) from each other.
Both are captured raw and classified at query time, so labels improve without an SDK release. Requires posthog>=7.42. stdio and in-memory transports carry no headers, so leave them unset there.
Stateless / multi-pod dispatchers
On a stateless deployment (a fresh server per request, often across pods) there's no connection to carry a session, so $session_id fragments and the client name/version — sent only at initialize — go missing from later requests. Add the mint middleware to your ASGI app once. It mints a self-encoded token onto the Mcp-Session-Id response header at initialize and decodes the client's replay on every later request, so every pod recovers the same values with no shared store:
The token is unsigned and carries only what the client volunteered at initialize — treat $session_id and $mcp_client_* as analytics labels, not authentication.