TypeScript MCP Analytics installation

Contents

Beta SDK

@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 importsMCP SDK majorProtocol revisions it serves
@modelcontextprotocol/sdkv12025-11-25 and earlier
@modelcontextprotocol/core, /server, /clientv22025-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.

Learn more

The wizard installs the package and adds instrument() for you. To install by hand, follow the steps below.

  1. Install the packages

    Required
    Terminal
    npm install @posthog/mcp posthog-node

    @posthog/mcp uses your posthog-node client to send events. You own that client's lifecycle.

  2. Wrap your server

    Required

    Call instrument(server, posthog) once, before the server accepts requests. Pass the McpServer or the low-level Server. The SDK also instruments tools that you register later.

    import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"
    import { PostHog } from "posthog-node"
    import { instrument } from "@posthog/mcp"
    const server = new McpServer({ name: "my-mcp-server", version: "1.0.0" })
    const posthog = new PostHog(process.env.POSTHOG_PROJECT_TOKEN, {
    host: "https://us.i.posthog.com", // or https://eu.i.posthog.com
    })
    const analytics = instrument(server, posthog)
    server.tool("search_events", { /* ... */ }, async (args) => {
    // your handler runs untouched
    })

    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

    Required

    posthog-node sends events in batches. Call posthog.shutdown() when the process stops:

    TypeScript
    process.on("SIGTERM", async () => {
    await posthog.shutdown()
    process.exit(0)
    })

    In serverless and edge functions, SIGTERM may not run. Call await posthog.flush() at the end of each invocation, or ctx.waitUntil(posthog.flush()) where the platform supports it.

  4. Check your first events

    Required

    Connect 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:

TypeScript
import { createMcpHandler } from "mcp-handler"
import { PostHog, instrument } from "@posthog/mcp"
// Create the client once at module scope, not per request.
const posthog = new PostHog(process.env.POSTHOG_PROJECT_TOKEN, {
host: "https://us.i.posthog.com",
})
const handler = createMcpHandler(
(server) => {
instrument(server, posthog)
server.registerTool("roll_dice", { /* ... */ }, async ({ sides }) => { /* ... */ })
},
{},
{ basePath: "/api" },
)
export { handler as GET, handler as POST }

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 distinctId from identify, 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:

TypeScript
import { Module } from "@nestjs/common"
import { McpModule } from "@rekog/mcp-nest"
import { PostHog, instrumentMutator } from "@posthog/mcp"
const posthog = new PostHog(process.env.POSTHOG_PROJECT_TOKEN, {
host: "https://us.i.posthog.com",
})
@Module({
imports: [
McpModule.forRoot({
name: "my-mcp-server",
version: "1.0.0",
serverMutator: instrumentMutator(posthog),
}),
],
})
export class AppModule {}

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:

TypeScript
serverMutator: (server) => {
const analytics = instrument(server, posthog)
return server
}

Read request headers on both SDK majors

This fails silently

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:

TypeScript
import { instrument, getRequestHeaders } from "@posthog/mcp"
instrument(server, posthog, {
identify: async (request, extra) => {
const token = getRequestHeaders(extra)?.["authorization"]
return token ? { distinctId: await resolveUserId(token) } : null
},
})

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:

TypeScript
new StreamableHTTPServerTransport({
sessionIdGenerator: undefined, // stateless
enableJsonResponse: true, // lets the SDK set the session header
})

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:

TypeScript
import { MCP_SESSION_HEADER, encodeSessionId, newSessionId } from "@posthog/mcp"
if (body?.method === "initialize" && !req.headers[MCP_SESSION_HEADER]) {
res.setHeader(
MCP_SESSION_HEADER,
encodeSessionId({
sessionId: newSessionId(),
clientName: body.params?.clientInfo?.name,
clientVersion: body.params?.clientInfo?.version,
})
)
}

Configuration

instrument(server, posthog, options?) takes these options:

OptionDefaultWhat it does
contexttrueAdd 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.
captureModeltrueCapture the calling model from client metadata or an llm_model argument.
enableConversationIdtrueAdd a conversation_id argument to group calls across requests.
identify–(request, extra) => UserIdentity \| null. Maps a request to one of your users.
reportMissingfalseAdd the get_more_tools tool for missing capabilities.
missingCapabilityToolName"get_more_tools"Renames the reportMissing tool.
enableExceptionAutocapturetrueEmit 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.
shouldRecordInputKeydeclared namesDecides 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.
loggerno-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:

TypeScript
import { PostHogMCP, getMoreToolsResult } from "@posthog/mcp"
const posthog = new PostHogMCP(process.env.POSTHOG_PROJECT_TOKEN, {
host: "https://us.i.posthog.com",
})

Call the capture methods from your dispatcher. They build the same $mcp_* events as instrument(), with the same redaction and size limits:

TypeScript
// When you answer tools/list:
const tools = posthog.prepareToolList(myTools, { reportMissing: true })
// When you answer tools/call, before the tool runs:
const prepared = posthog.prepareToolCall(name, request.params.arguments)
if (prepared.isMissingCapability) {
posthog.captureMissingCapability({ context: prepared.intent, distinctId: user.id })
return getMoreToolsResult()
}
const start = Date.now()
const result = await runTool(name, prepared.args)
// Adds the conversation handle to the result, and returns the session to capture:
const final = posthog.prepareToolResult(result, prepared)
posthog.captureToolCall({
toolName: name,
intent: prepared.intent,
intentSource: prepared.intentSource,
llmModel: prepared.llmModel,
llmModelSource: prepared.llmModelSource,
parameters: prepared.args,
response: final.result,
durationMs: Date.now() - start,
isError: false,
sessionId: final.sessionId,
conversationId: final.conversationId,
distinctId: user.id,
protocolVersion: requestProtocolVersion,
groups: { organization: user.orgId },
})
return final.result
// Only on a 2025-11-25 initialize handshake:
posthog.captureInitialize({
clientName: "claude-code",
clientVersion: "1.2.3",
protocolVersion: "2025-11-25",
distinctId: user.id,
})

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:

FieldBecomesNotes
distinctIddistinct_idTurns on person processing. Omit it for anonymous traffic.
sessionId$session_idOmitted when you don't pass one.
protocolVersion$mcp_protocol_versionPass the revision of each request.
clientUserAgent$mcp_client_user_agentThe raw User-Agent header on HTTP transports.
vendorClient$mcp_vendor_clientA vendor client header, such as x-anthropic-client.
groups$groups{ groupType: groupKey }.
setProperties$setPerson properties, such as name or plan.
propertiesevent propertiesExtra properties, added as they are.
timestampevent timeDefaults 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.

Still have questions?

Was this page useful?