TypeScript MCP Analytics installation
Contents
@posthog/mcp is in beta (pre-1.0). Minor 0.x releases may contain breaking API changes until v1. Pin a version during the beta.
@posthog/mcp instruments servers built on the official MCP TypeScript SDK. It supports both SDK majors:
| Your imports | MCP SDK major | Protocol revisions it serves |
|---|---|---|
@modelcontextprotocol/sdk | v1 | 2025-11-25 and earlier |
@modelcontextprotocol/core, /server, /client | v2 | 2025-11-25 and 2026-07-28 |
You need Node.js 20.20+ or 22.22+ and a PostHog project token. Using a dispatcher with no MCP SDK server object? See Custom dispatchers.
The wizard installs the package and adds instrument() for you. To install by hand, follow the steps below.
- 1
Install the packages
RequiredTerminal@posthog/mcpuses yourposthog-nodeclient to send events. You own that client's lifecycle. - 2
Wrap your server
RequiredCall
instrument(server, posthog)once, before the server accepts requests. Pass theMcpServeror the low-levelServer. The SDK also instruments tools that you register later.instrument()returns an analytics handle for custom events. A second call on the same server logs a warning and returns early. - 3
Send queued events on shutdown
Requiredposthog-nodesends events in batches. Callposthog.shutdown()when the process stops:TypeScriptIn serverless and edge functions,
SIGTERMmay not run. Callawait posthog.flush()at the end of each invocation, orctx.waitUntil(posthog.flush())where the platform supports it. - 4
Check your first events
RequiredConnect an agent to your server and call a tool. Then open the activity feed and filter for
event = $mcp_tool_call. See the event reference for every property.
Frameworks
Next.js and Vercel (mcp-handler)
mcp-handler gives you an McpServer in its setup callback. Call instrument() there:
mcp-handler creates a new server for each request and sends no Mcp-Session-Id header. Without another signal, each request gets its own $session_id. To group a client's calls:
- Return a
distinctIdfromidentify, such as the OAuth subject. This needs no client changes. - Keep conversation IDs on (the default). Calls group when the agent echoes the handle.
Flush at the end of each invocation, as in step 3.
NestJS (@rekog/mcp-nest)
@rekog/mcp-nest creates the server through McpModule.forRoot(...). Add instrumentMutator to its serverMutator hook:
instrumentMutator(posthog) calls instrument() and returns the server, not the analytics handle. It also captures tools that mcp-nest registers later. If you need the handle for custom events, call instrument() yourself:
Read request headers on both SDK majors
MCP SDK v1 stores headers at extra.requestInfo.headers. v2 stores a WHATWG Request at extra.http.req. Reading the v1 location on v2 returns undefined, so identify() can return null and send anonymous events without an error.
Use getRequestHeaders(extra) in identify, intentFallback, and eventProperties. It works on both majors and returns lowercase keys:
Stateless and multi-pod servers
A stateless server creates a new instance for each request, often on a different pod. Without correlation, each request gets its own $session_id, and events after initialize lose the client name and version.
On 2025-11-25 traffic, the SDK fixes this without a shared store or sticky routing. At initialize, it puts a token with the session ID and client metadata in the Mcp-Session-Id response header. Clients send the header back, so any pod reads the same values. The 2026-07-28 revision has no initialize, so use conversation IDs there.
The SDK can only send the token in JSON mode. Set enableJsonResponse: true on a fresh transport per request:
With @rekog/mcp-nest, set streamableHttp: { statelessMode: true, enableJsonResponse: true } on the module. The legacy path in createMcpHandler can't send the token, so $mcp_client_name and $mcp_client_version are absent there.
If you must stream (SSE), set the header yourself before the response headers go out:
Configuration
instrument(server, posthog, options?) takes these options:
| Option | Default | What it does |
|---|---|---|
context | true | Add a context argument to capture agent intent. Pass { description } to change its prompt. |
intentFallback | – | (request, extra) => string \| null. Supplies intent when the agent sends no context. |
captureModel | true | Capture the calling model from client metadata or an llm_model argument. |
enableConversationId | true | Add a conversation_id argument to group calls across requests. |
identify | – | (request, extra) => UserIdentity \| null. Maps a request to one of your users. |
reportMissing | false | Add the get_more_tools tool for missing capabilities. |
missingCapabilityToolName | "get_more_tools" | Renames the reportMissing tool. |
enableExceptionAutocapture | true | Emit a $exception event for each failed tool call. |
beforeSend | – | (event) => event \| null. Change or drop each event before it's sent. See Privacy. |
eventProperties | – | (request, extra) => Record<string, unknown>. Adds properties to every event. |
serverBuild | – | An immutable build ID, such as a Git SHA, sent as $mcp_server_build. 1 to 256 characters. |
shouldRecordInputKey | declared names | Decides which argument names $mcp_input_keys records. Other names become one [redacted] entry. |
resolveInputAliases | – | (toolName) => aliases. Lists alternative argument names, recorded as $mcp_input_aliases_used. |
logger | no-op | (message) => void. A stdio-safe sink for SDK warnings. |
Custom dispatchers
instrument() needs an MCP SDK server object. A Hono, Express, Cloudflare Workers, or edge handler that implements the protocol itself has none. Use PostHogMCP instead. It extends the posthog-node client and takes the same constructor arguments:
Call the capture methods from your dispatcher. They build the same $mcp_* events as instrument(), with the same redaction and size limits:
These methods queue events and never throw, so analytics can't break your tool. Flush at the end of each invocation, as in step 3.
Every capture method takes these fields:
| Field | Becomes | Notes |
|---|---|---|
distinctId | distinct_id | Turns on person processing. Omit it for anonymous traffic. |
sessionId | $session_id | Omitted when you don't pass one. |
protocolVersion | $mcp_protocol_version | Pass the revision of each request. |
clientUserAgent | $mcp_client_user_agent | The raw User-Agent header on HTTP transports. |
vendorClient | $mcp_vendor_client | A vendor client header, such as x-anthropic-client. |
groups | $groups | { groupType: groupKey }. |
setProperties | $set | Person properties, such as name or plan. |
properties | event properties | Extra properties, added as they are. |
timestamp | event time | Defaults to the time of the call. |
The instrument() hooks (identify, intentFallback, eventProperties) don't run on this path. Pass identity on each call. prepareToolCall and prepareToolResult handle intent, model capture, and conversation IDs. On a stateless server, pass the original tool as the originalTool option of prepareToolCall, so a process that didn't serve tools/list still knows which arguments it added. On 2026-07-28, don't send $mcp_initialize, because that revision has no handshake.