Installing the MCP analytics SDK
Contents
@posthog/mcp is in beta (pre-1.0). The API may still change — including breaking changes in minor 0.x releases — until v1, so pin a version while we iterate. A wizard-driven install (npx @posthog/wizard mcp-analytics) is on the roadmap and will replace most of this page once it ships.
Requirements
- Node.js 18 or later
- A TypeScript or JavaScript MCP server built on
@modelcontextprotocol/sdk. (Running a custom dispatcher with no server object to wrap? See Custom servers.) - A PostHog project API key (
phc_…)
Install
You bring your own posthog-node client (the same pattern as @posthog/ai) and pass it to instrument() as the required second argument. You own its lifecycle — call posthog.shutdown() or posthog.flush() yourself.
Wrap your server
instrument(server, posthog, options?) is the only function you need to call. The posthog client is a required positional argument; options is optional. It returns an analytics handle (used for custom events). It's idempotent per server — calling it twice on the same server logs a warning and returns early.
Low-level Server
If you registered your tools against the raw protocol Server from @modelcontextprotocol/sdk/server/index.js:
High-level McpServer
If you use the typed McpServer wrapper from @modelcontextprotocol/sdk/server/mcp.js, pass it in directly — the SDK will unwrap it and also install a proxy on _registeredTools, so any tool you register after instrument() is also wrapped:
Next.js / Vercel (mcp-handler)
mcp-handler gives you a standard McpServer in its setup
callback, so you instrument it the same way — one line, before or after you register tools:
Grouping a client's calls
On Vercel, mcp-handler's streamable-HTTP transport is stateless: it spins up a fresh server per
request and issues no Mcp-Session-Id, so there's no connection for the SDK to derive a shared
$session_id from — left alone, every request lands in its own session.
The robust way to group is by user. Pass identify and
return a distinctId from your auth (e.g. the OAuth subject) — that sets distinct_id, so a person's
calls group together no matter how many stateless requests they span, and it requires nothing from the
client:
For finer, per-conversation grouping you can also enable
enableConversationId: the SDK adds a conversation_id
argument, generates one when the client doesn't send it, and asks the agent to echo it on later calls,
correlating them via $mcp_conversation_id. It's best-effort — it works by appending a short
instruction to the tool result, which a cooperative agent echoes but some clients ignore or treat as
untrusted server content (the same wariness they apply to prompt injection). Use it when you control the
client or that trade-off is acceptable; otherwise stick with identify.
Flushing
posthog-node batches events, and a serverless function can freeze before they send. Flush at the end
of the invocation — await posthog.flush(), or ctx.waitUntil(posthog.flush()) to keep the runtime
alive until it completes.
Configuration
The posthog client is passed as the required second positional argument — not in this options object. instrument() accepts these options as an optional third argument:
| Option | Type | Default | What it does |
|---|---|---|---|
logger | (message: string) => void | no-op | STDIO-safe log sink for SDK-internal warnings. MCP STDIO transports cannot use console.*, so the default discards. Wire your own to surface warnings during development. |
enableExceptionAutocapture | boolean | true | When false, a failed tool call does not emit the $exception sibling event. |
context | boolean \| { description: string } | true | Inject a required context argument into every tool schema. See Capturing agent intent. |
intentFallback | (request, extra) => string \| Promise<string \| null \| undefined> | — | Called when the agent didn't pass a context argument. See Capturing agent intent. |
enableConversationId | boolean | false | Inject an optional conversation_id argument into every tool. See Conversation IDs. |
reportMissing | boolean | false | Register the get_more_tools virtual tool. See Missing capability. |
identify | async (request, extra) => UserIdentity \| null \| UserIdentity | — | Map an MCP request to one of your users. See Identifying users. |
beforeSend | (event) => event \| null \| undefined \| Promise<...> | — | Runs on each fully-built PostHog payload right before send. Return the (possibly mutated) event to send it, or a nullish value to drop it. See Privacy. |
eventProperties | async (request, extra) => Record<string, unknown> | — | Properties merged onto every event. See Custom events and metadata. |
Graceful shutdown
The posthog-node client queues and batches events asynchronously, and you own its lifecycle. Call posthog.shutdown() from your SIGTERM / beforeExit handler so in-flight events aren't dropped:
If you only want to drain the queue without tearing the client down, call posthog.flush() instead.
In serverless or edge environments where SIGTERM isn't reliable, flush explicitly at the end of each invocation — await posthog.flush(), or ctx.waitUntil(posthog.flush()) on platforms that support it — rather than relying on a shutdown signal.
What happens after install
As soon as the wrapper is in place, every MCP request handled by the server emits a PostHog event:
$mcp_tool_callper tool invocation$mcp_tools_listpertools/listresponse$mcp_initializeper client handshake$mcp_resource_read,$mcp_resources_list,$mcp_prompt_get,$mcp_prompts_listas applicable$exceptionwhenever a tool throws or returnsisError: true
All events share a $session_id derived from the MCP protocol session (so the same connection always maps to the same PostHog session). See the event reference for the full catalog.