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 — capture, identify, flush, shutdown, and feature flags all work unchanged — with captureToolCall and captureInitialize added on top. You resolve identity and context per request and call those methods yourself. They build the same canonical $mcp_* events as instrument() (same sanitization, truncation, and $exception fan-out) and hand them to the inherited capture(), so nothing downstream (insights, dashboards, error tracking) can tell the difference.

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?)

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_API_KEY, {
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)
groups: { organization: user.orgId }, // → $groups
properties: { $mcp_client_name: "claude-code" }, // any extra props, spread verbatim
})
// On the initialize handshake:
posthog.captureInitialize({
clientName: "claude-code",
clientVersion: "1.2.3",
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).
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.
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 (vs 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.
  • The injected context argument, intentFallback, reportMissing, and conversation_id — these patch tool schemas and request handlers, which only the wrapping path can do.

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
from posthog.mcp import PostHogMCP, get_more_tools_result
posthog = PostHogMCP("phc_your_project_api_key", host="https://us.i.posthog.com")
# Decorate your tools/list response so agents state their intent (and, optionally,
# advertise the get_more_tools virtual tool):
tools = posthog.prepare_tool_list(my_tools, report_missing=True)
# On an inbound tools/call, pull the intent and strip the injected `context`:
prepared = posthog.prepare_tool_call(name, arguments)
if prepared.is_missing_capability:
posthog.capture_missing_capability(context=prepared.intent, distinct_id=user_id)
return get_more_tools_result()
result = run_tool(name, prepared.args)
# Capture the call (fire-and-forget, like posthog.capture):
posthog.capture_tool_call(
name,
intent=prepared.intent,
intent_source=prepared.intent_source,
parameters=arguments,
response=result,
duration_ms=elapsed_ms,
is_error=False,
distinct_id=user_id,
session_id=mcp_session_id,
groups={"organization": org_id},
)
# On the handshake:
posthog.capture_initialize(client_name="claude-code", client_version="1.2.3", distinct_id=user_id)
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 (e.g. host). 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.

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.

Community questions

Was this page useful?