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:
Resolve tool arguments on fresh low-level servers
A fresh low-level Server has not served tools/list, so it does not know which context and llm_model arguments the SDK added. A strict input schema can reject these arguments before the tool runs.
Use resolveOriginalTool to return each tool's original input schema from your registry. The callback must return the schema from before PostHog adds its arguments:
The SDK strips only arguments that it added. It reads a Zod schema as the MCP SDK advertises it. If your server advertises a custom JSON Schema, return that schema instead.
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 |