Instrumenting a custom server
Contents
instrument() wraps the request handlers of a @modelcontextprotocol/sdk Server or McpServer. A custom dispatcher has no server object to wrap. Examples include Hono, Express, Cloudflare Workers, and Vercel edge functions that implement the MCP protocol directly.
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 additional $exception events.
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:
PostHogMCP provides the standard PostHog client options and methods. Its beforeSend hook also applies to MCP events. Set enableExceptionAutocapture: false to stop additional $exception events for failed calls.
The instrument() hooks (identify, context, intentFallback, eventProperties) do not run on this path. Pass identity and properties on each capture call.
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 updates a person profile. 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. PostHog does not store $set on the event. Query these values 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, the SDK emits an additional $exception unless enableExceptionAutocapture is false. It uses the error you supply. If no error is available, it creates a generic exception from the tool name.
captureToolCall and captureInitialize queue events without waiting for delivery, like posthog.capture(). They do not throw, so analytics failures do not interrupt your tool. Flush at the end of each serverless or edge invocation to send queued events.
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 – enabled by default in both SDKs' preparation helpers. Use
prepareToolList()to advertise the argument, thenprepareToolCall()to extract it. Pass itsllmModelandllmModelSourcetocaptureToolCall(). The experimental Ruby client is opt-in: passcapture_model: truetoprepare_tool_list.
For model capture on a fresh dispatcher, pass the application's original tool as originalTool in the preparation options. Pass requestMeta there to capture recognized client metadata. Python uses the equivalent snake_case fields and keyword arguments.
The 2026-07-28 revision has no initialize handshake or protocol session. Don't fabricate $mcp_initialize. Capture each request's protocolVersion. For correlation across requests, pass an authenticated user ID or your own stable session ID.
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 to capture $mcp_error_message and $mcp_error_type from the exception. The SDK sanitizes the message and limits it to 2048 characters. Set error_type="timeout", or another category, to replace the exception class name. The SDK also unwraps the generic ToolError from MCP SDK 2.x.
Attributing the caller
clientInfo.name reports claude-code for the CLI, Agent SDK, VS Code extension, and desktop app. That name alone cannot distinguish these clients, so the harness breakdown can show mostly "Other". Custom dispatchers must pass the transport headers below. instrument() reads them automatically:
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. It separates products but cannot distinguish clients within one product. Claude.ai web, desktop, and mobile connectors use the same fetcher and header value. They all receive the "Claude.ai" label.
Both are captured raw and classified at query time, so labels improve without an SDK release. stdio and in-memory transports carry no headers, so leave them unset there.
Stateless / multi-pod dispatchers
A stateless deployment creates a new server per request, often across pods. Without correlation, $session_id differs between requests. Client name and version arrive only at initialize, so later requests lose these values.
Add the session middleware to your ASGI app once. At initialize, it puts a token in the Mcp-Session-Id response header. It decodes the token when clients resend it. Each pod recovers the same values without shared storage:
The token is unsigned and contains only client-provided values from initialize. Use $session_id and $mcp_client_* as analytics labels, not authentication.
Ruby
PostHog::MCP::Client ships in posthog-ruby and is experimental and not officially supported: we don't provide support for it, and its method signatures may change in a minor release. See the Ruby section of the installation docs.
Do you need this?
If your Ruby server is an MCP::Server from the official mcp gem, you don't: add PostHog::MCP.instrument(server, posthog) and every request is captured. See Ruby.
You need this section only when your app speaks the MCP protocol itself: a Rack or Rails endpoint that parses the JSON-RPC body, routes tools/list and tools/call by hand, and never builds an MCP::Server. There's no object to wrap, so you tell PostHog what happened.
Set up
PostHog::MCP::Client is a PostHog::Client subclass. Create it once, where you create your PostHog client today, and use it for everything else too (capture, feature flags, flush). It needs nothing beyond posthog-ruby, no mcp gem.
Two constructor options are specific to MCP: missing_capability_tool_name: renames the get_more_tools virtual tool, and mcp_exception_autocapture: false turns off the $exception sibling event for failed calls.
Step 1: Prepare the tool list
When you answer tools/list, pass your tool descriptors (the Hashes you return on the wire, with name and inputSchema) through prepare_tool_list. It returns new Hashes; your originals are not changed.
This does two things:
- It adds a required
contextstring argument to every tool. The agent fills it with why it is calling the tool, and you capture that as$mcp_intentin step 2. See Capturing agent intent. Passcontext: falseto skip it, orcontext: { description: "..." }to change the prompt. - With
report_missing: true, it appends theget_more_toolsvirtual tool, so agents can tell you which capability they were missing. See Missing capabilities.
Pass capture_model: true to also add an optional llm_model argument, where the agent reports its model. A tool whose inputSchema is composed (oneOf, allOf, anyOf) or a $ref is returned unchanged.
Step 2: Prepare each tool call
When you answer tools/call, pass the tool name, the raw arguments, and the tool's own inputSchema through prepare_tool_call before you run the tool. It returns a PreparedToolCall with:
args: the arguments without the injectedcontextandllm_model, so your tool never sees themintentandintent_source: the agent's stated reason, ready to capturellm_modelandllm_model_source: the agent's self-reported model, when you setcapture_model: trueis_missing_capability:truewhen the agent called theget_more_toolsvirtual tool
Pass the same inputSchema Hash you gave to prepare_tool_list. If the tool declares its own context or llm_model field, that field then stays in args and is not read as analytics. Without input_schema:, both names are always removed.
Step 3: Capture what happened
After the tool runs, call capture_tool_call. Pass the prepared intent (and llm_model: and llm_model_source:, if you capture the model), the arguments and result, the duration, and whether it failed. On a failure pass error: (the exception, or a message); PostHog fills $mcp_error_type, $mcp_error_message, and emits the $exception sibling.
The other capture methods follow the same shape and map to the events in the event reference:
| Method | Event | Call it when |
|---|---|---|
capture_initialize(client_name:, client_version:, protocol_version:, ...) | $mcp_initialize | You answer an initialize handshake. Read the client name and version from params.clientInfo. |
capture_tools_list(tool_names:, ...) | $mcp_tools_list | You answer tools/list. Pass the names you advertised. |
capture_tool_call(name, ...) | $mcp_tool_call (+ $exception) | A tool ran, succeeded or failed. |
capture_missing_capability(context:, ...) | $mcp_missing_capability | The agent called the get_more_tools virtual tool. |
Step 4: Attribute the caller
Every capture method accepts the same attribution keywords. Pass them on every call so the events for one client group together:
| Keyword | Becomes | Where to get it |
|---|---|---|
distinct_id: | the event's person | Your auth (OAuth subject, API key owner). See Identifying users. |
session_id: | $session_id | The Mcp-Session-Id header (see below). Without it, events are anonymous per request. |
set_properties: | $set | Person properties such as name or plan. |
groups: | $groups | { organization: org_id } for group analytics. |
client_user_agent:, vendor_client: | $mcp_client_user_agent, $mcp_vendor_client | The User-Agent and X-Anthropic-Client request headers. |
protocol_version: | $mcp_protocol_version | params.protocolVersion on initialize, or the MCP-Protocol-Version header. |
For session_id:, the simplest option is use PostHog::MCP::RackMiddleware in your Rack stack. The middleware reads no request or response body – you already parse the JSON-RPC body yourself – so when you answer an accepted initialize, call the mint hook it leaves in env["posthog_mcp.mint"]:
The middleware attaches the minted token to the Mcp-Session-Id response header, clients replay it on every request, and on those requests the decoded token is waiting in env["posthog_mcp.session"] – so everywhere else you just pass session_id: env["posthog_mcp.session"]&.session_id. The hook is absent (nil) when the client already replayed a token, and returns nil for a 2026-07-28 client, which must not be answered with an Mcp-Session-Id. If you issue your own session header instead, pass PostHog::MCP.derive_session_id_from_mcp_session(your_id) so the same connection always maps to the same $session_id.
Step 5: Flush
PostHog::MCP::Client batches events in the background like any posthog-ruby client. Call posthog.flush at the end of a short-lived request handler, or posthog.shutdown when the process stops.