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 serverUse
Built on @modelcontextprotocol/sdk's Server / McpServerinstrument(server, posthog, options?)
A custom HTTP/Hono/edge dispatcher with no server object to wrapnew PostHogMCP(apiKey, options?)
A server in a language with no MCP Analytics SDKCapture the events yourself

The examples below are TypeScript. Python has the same helper with the same methods in snake_case, so skip to Python. Neither language yours? See Any other language.

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:

TypeScript
import { PostHogMCP } from "@posthog/mcp"
const posthog = new PostHogMCP(process.env.POSTHOG_PROJECT_TOKEN, {
host: "https://us.i.posthog.com", // or https://eu.i.posthog.com
// standard posthog-node options apply, e.g. beforeSend, enableExceptionAutocapture
})

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():

TypeScript
// On a tools/call, after the tool runs:
posthog.captureToolCall({
toolName: "search_events",
parameters: request.params.arguments,
response: result,
durationMs: Date.now() - start,
isError: false,
distinctId: user.id, // → distinct_id (enables person processing)
sessionId: mcpSessionId, // → $session_id (omitted if you don't pass one)
protocolVersion: requestProtocolVersion, // → $mcp_protocol_version
groups: { organization: user.orgId }, // → $groups
properties: { $mcp_client_name: "claude-code" }, // any extra props, spread verbatim
})
// Only on a 2025-11-25 initialize handshake:
posthog.captureInitialize({
clientName: "claude-code",
clientVersion: "1.2.3",
protocolVersion: "2025-11-25",
distinctId: user.id,
})
// Custom events use the inherited posthog-node capture():
posthog.capture({
distinctId: user.id,
event: "feedback_submitted",
properties: { rating: 5 },
})

Fields shared by every method

FieldMaps toNotes
distinctIddistinct_idSupplying 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_idOmitted 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_versionPass 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_agentPass the raw User-Agent header on HTTP transports.
vendorClient$mcp_vendor_clientPass 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$setPerson 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.
propertiesspread verbatimExtra event properties, sitting alongside the $mcp_* keys. Values must be JSON-serializable.
timestampevent timeDefaults 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).

Analytics never breaks your request

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_id or inactivity rollover. Pass your own sessionId.
  • Identity caching / $identify dedupe — pass distinctId (and optional setProperties) on each call.
  • Automatic intent and missing-capability handling — use prepareToolList() and prepareToolCall(), then pass their output to the matching capture method.
  • Conversation IDs — pass your own stable sessionId. The custom dispatcher helpers don't inject or echo conversation_id.
  • Model capture — captureModel and the injected llm_model argument are currently available only through instrument() on a supported TypeScript server wrapper. Don't add $mcp_llm_model manually.

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:

TypeScript
// at the end of the request/invocation
await posthog.flush()
// or keep the runtime alive until the flush completes
ctx.waitUntil(posthog.flush())

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:

Python
import time
from posthog.mcp import PostHogMCP, get_more_tools_result
posthog = PostHogMCP("phc_your_project_api_key", host="https://us.i.posthog.com")
# Advertise your tools with the injected `context` intent argument (and, optionally,
# the get_more_tools virtual tool):
tools = posthog.prepare_tool_list(my_tools, report_missing=True)
def handle_tools_call(request, name, arguments):
# Pull the agent's intent off the call and strip the injected `context`:
prepared = posthog.prepare_tool_call(name, arguments)
# Pass these on every capture — see "Attributing the caller" below:
common = dict(
distinct_id=user_id,
session_id=mcp_session_id,
client_user_agent=request.headers.get("user-agent"),
vendor_client=request.headers.get("x-anthropic-client"),
groups={"organization": org_id},
)
if prepared.is_missing_capability:
posthog.capture_missing_capability(context=prepared.intent, **common)
return get_more_tools_result()
start = time.monotonic()
try:
result = run_tool(name, prepared.args)
except Exception as exc:
posthog.capture_tool_call(
name,
parameters=prepared.args,
duration_ms=(time.monotonic() - start) * 1000,
is_error=True,
error=exc, # → $mcp_error_message, $mcp_error_type, and the $exception sibling
**common,
)
raise
posthog.capture_tool_call(
name,
intent=prepared.intent,
intent_source=prepared.intent_source,
parameters=prepared.args,
response=result,
duration_ms=(time.monotonic() - start) * 1000,
**common,
)
return result

Capture the handshake and the tool listing the same way:

Python
posthog.capture_initialize(client_name="claude-code", client_version="1.2.3", **common)
posthog.capture_tools_list(tool_names=[t["name"] for t in tools], **common)
posthog.flush() # PostHogMCP is a posthog client — flush/shutdown it yourself

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 like x-anthropic-client, the only thing that separates Anthropic's pooled surfaces (Claude.ai, Cowork, Claude Design) from each other. It separates the products, not surfaces within one: claude.ai web, desktop, and mobile connectors all arrive through Anthropic's server-side fetcher with the same header value, so they share the single "Claude.ai" label.

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:

Python
from posthog.mcp import PostHogMcpStatelessSessionMiddleware, get_mcp_session
app.add_middleware(PostHogMcpStatelessSessionMiddleware)
# ...then in your request handler, feed the recovered session into each capture.
# The token carries the client identity too — pass it as $mcp_client_* properties
# (capture_tool_call takes session_id directly, client name/version via properties):
sess = get_mcp_session(request) # None until the client replays the token
posthog.capture_tool_call(
name,
session_id=sess.session_id if sess else None,
intent=prepared.intent,
properties={
"$mcp_client_name": sess.client_name if sess else None,
"$mcp_client_version": sess.client_version if sess else None,
},
)

The token is unsigned and carries only what the client volunteered at initialize — treat $session_id and $mcp_client_* as analytics labels, not authentication.

Any other language

MCP Analytics reads events, not SDKs. Every query behind the product filters on the event name and on $mcp_source. None of them looks at which library sent the data. A server written in Elixir, Go, Ruby, Rust, or anything else populates the same dashboards as a TypeScript one, as long as it captures the canonical $mcp_* events.

Use this path when your language has no MCP Analytics SDK. If you write TypeScript or Python, use PostHogMCP above instead. It builds the same events, and it does the sanitization, truncation, and $exception fan-out for you.

You need a way to send a PostHog event with arbitrary properties. A PostHog library for your language is the easy route. A plain HTTPS POST to the capture API works too.

The minimum event

One event puts your server on the dashboards: $mcp_tool_call, carrying $mcp_source. A call without that marker is invisible to every MCP Analytics view.

PropertyTypeWhy you need it
$mcp_sourcestringMust be the exact string "posthog_mcp_analytics". Every query filters on it.
$mcp_tool_namestringThe tool the agent called. Groups the per-tool views.
$mcp_server_namestringYour server's name. The server list skips events that leave it empty.
$mcp_is_errorbooleanDrives every error rate. Send false on success, not nothing.
$mcp_duration_msnumberWall-clock milliseconds. Drives the latency percentiles.
$session_idstringGroups one client's calls into a session. Use a stable id per connection or per user.

Add the rest as you go. Each one turns on a view rather than a column:

PropertyWhat it unlocks
$mcp_tool_descriptionThe description the agent read, so you can see whether a rewrite changed behavior.
$mcp_parameters, $mcp_responsePer-call inspection and response-size analysis. Redact these yourself.
$mcp_error_type, $mcp_error_messageFailure buckets with a cause, instead of a count of empty rows.
$mcp_intent, $mcp_intent_sourceAgent intent and intent clustering.
$mcp_client_name, $mcp_client_user_agent, $mcp_vendor_clientThe harness breakdown. Without them every call reads "Other".
$mcp_protocol_versionSpec-revision adoption, and error rate broken down by revision.

The event reference documents every property and the other events you can send: $mcp_tools_list for advertised-but-never-called tools, $mcp_initialize for a 2025-11-25 handshake, and $mcp_missing_capability for gaps the agent reports.

Example: Elixir

posthog captures any event with any properties, so a hand-rolled dispatcher needs no new dependency:

Elixir
defmodule MyServer.MCPAnalytics do
@server_name "my-elixir-server"
@server_version "1.0.0"
def tool_call(tool_name, prepared, result, duration_ms, req) do
PostHog.capture("$mcp_tool_call", %{
distinct_id: req.user_id,
"$session_id": req.session_id,
"$mcp_source": "posthog_mcp_analytics",
"$mcp_server_name": @server_name,
"$mcp_server_version": @server_version,
"$mcp_tool_name": tool_name,
"$mcp_parameters": redact(prepared.args),
"$mcp_response": redact(result),
"$mcp_duration_ms": duration_ms,
"$mcp_is_error": false,
"$mcp_intent": prepared.intent,
"$mcp_intent_source": "context_parameter",
"$mcp_protocol_version": req.protocol_version,
"$mcp_client_name": req.client_name,
"$mcp_client_user_agent": req.user_agent
})
end
end

PostHog.capture/2 reads distinct_id out of the properties map, so anonymous traffic can leave it out.

A failed call is the same event with three keys changed. $mcp_error_type is your own low-cardinality label, so the failures view groups by cause:

Elixir
"$mcp_is_error": true,
"$mcp_error_type": "timeout",
"$mcp_error_message": Exception.message(exception)

What you take on

The wrapping SDKs do work that a raw capture call does not:

  • Redaction and truncation. $mcp_parameters and $mcp_response carry whatever your tools received and returned, including credentials an agent pasted into an argument. Strip them before you capture, and cap the size. Read Privacy for what the SDKs remove.
  • Intent capture. Add a required context string argument to each tool schema you advertise, remove it from the arguments before the tool runs, and send it as $mcp_intent with $mcp_intent_source set to "context_parameter".
  • Sessions. Nothing derives $session_id for you. Use the transport session on 2025-11-25, or the authenticated user id on a stateless server.
  • Error tracking. The $exception sibling event is not automatic. Report the failure through your language's error tracking integration if you want the stack trace grouped as an issue.
  • Model capture. Leave $mcp_llm_model alone. It is only trustworthy when an SDK owns the injected llm_model argument.
Tell us what you build

The events are the contract and they do not churn, so a hand-rolled server keeps working. If you would rather have a real SDK, open an issue on the PostHog library you use and describe the server you run.

Still have questions?

Was this page useful?