Installing the MCP Analytics SDK

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.

Requirements

  • Node.js 20.20+ or 22.22+ (TypeScript/JavaScript), or Python 3.10+ – see Python below
  • An MCP server built on either TypeScript SDK major – @modelcontextprotocol/sdk (v1) or @modelcontextprotocol/{core,server,client} (v2) – or either official Python MCP SDK major (mcp>=1.26,<3). jlowin's standalone fastmcp package is also supported. See MCP SDK v2. (Running a custom dispatcher with no server object to wrap? See Custom servers.)
  • A PostHog project token (phc_…)

AI wizard

The wizard installs the package, adds your posthog-node client, and configures instrument(). It also supports LLM coding agents, such as Cursor and Bolt:

Learn more

For manual installation, follow the steps below.

Install

Terminal
npm install @posthog/mcp posthog-node
# or pnpm add @posthog/mcp posthog-node
# or yarn add @posthog/mcp posthog-node

Pass your posthog-node client to instrument() as the required second argument. This follows the @posthog/ai pattern. You manage the client lifecycle. Call posthog.shutdown() or posthog.flush() to send queued events.

Wrap your server

Call instrument(server, posthog, options?) once per server. The posthog client is required. The options argument is optional. The function returns an analytics handle for custom events. A second call on the same server logs a warning and returns early.

Both SDKs capture intent, models, and exceptions and enable conversation IDs by default. Missing-capability reporting and agent feedback are opt-in.

Low-level Server

If you registered your tools against the raw protocol Server from @modelcontextprotocol/sdk/server/index.js:

TypeScript
import { Server } from "@modelcontextprotocol/sdk/server/index.js"
import { PostHog } from "posthog-node"
import { instrument } from "@posthog/mcp"
const server = new Server({ 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
})
// register your tools as usual...
const analytics = instrument(server, posthog)

High-level McpServer

Pass the typed McpServer wrapper directly to instrument(). The SDK unwraps it and adds a proxy to _registeredTools. This proxy 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",
})
const analytics = instrument(server, posthog)
server.tool("search_events", { /* ... */ }, async (args) => {
// your handler runs untouched
})

Options, callbacks, and events work the same on both majors. See MCP SDK v2 for the differences.

Next.js / Vercel (mcp-handler)

mcp-handler provides a standard McpServer in its setup callback. Call instrument() in that callback, before or after you register tools:

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", // or https://eu.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 }

Grouping a client's calls

On Vercel, mcp-handler creates a new server for each request and provides no Mcp-Session-Id header. Without another correlation signal, the SDK assigns a separate $session_id to each request.

Use identify to group calls by user. Return a distinctId from your authentication data, such as the OAuth subject. The SDK uses this value as distinct_id across requests. This requires no client changes:

TypeScript
instrument(server, posthog, {
identify: (request, extra) => ({ distinctId: getUserId(extra) }),
})

enableConversationId is on by default. The SDK adds a conversation_id argument and returns a handle in eligible tool results. The tool schema asks the agent to reuse this handle. Correlation is best-effort because some clients ignore the handle or treat tool output as untrusted content.

If this behavior doesn't suit your client, set enableConversationId: false and use identify for user-level grouping.

Flushing

posthog-node batches events. A serverless function can freeze before the client sends them. Call await posthog.flush() at the end of each invocation. On supported platforms, ctx.waitUntil(posthog.flush()) keeps the runtime active until the flush completes.

NestJS (@rekog/mcp-nest)

@rekog/mcp-nest creates the server through McpModule.forRoot(...). You define tools with @Tool() decorators. Add instrumentMutator to the module's serverMutator hook:

TypeScript
import { Module } from "@nestjs/common"
import { McpModule } from "@rekog/mcp-nest"
import { PostHog, instrumentMutator } from "@posthog/mcp"
// Create the client once at module scope.
const posthog = new PostHog(process.env.POSTHOG_PROJECT_TOKEN, {
host: "https://us.i.posthog.com", // or https://eu.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. It does not return the analytics handle. The SDK also captures tools that mcp-nest registers after the mutator runs.

instrumentMutator(posthog) uses the same defaults, including on fresh server instances. Conversation correlation requires the agent to echo the handle.

If you need the analytics handle for custom events, call instrument() directly inside the mutator and return the server yourself:

TypeScript
serverMutator: (server) => {
const analytics = instrument(server, posthog)
// ...use `analytics.capture(...)` elsewhere...
return server
}

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. Client name and version arrive only at initialize, so later events lose these values.

The SDK requires no shared session store or sticky routing. At initialize, it creates a token that contains the session ID and client metadata. It sends this token in the Mcp-Session-Id response header. Clients resend the header on later requests, so any pod can read the same values. No client changes are required.

This applies to 2025-11-25 traffic

The 2026-07-28 revision has no initialize or Mcp-Session-Id, so this section does not apply. See Sessions on 2026-07-28.

Streamable HTTP needs enableJsonResponse: true

The SDK can send its session token only in JSON mode. In SSE mode, StreamableHTTPServerTransport creates response headers before your initialize handler runs. The token is missing from the response. Without another correlation signal, the SDK assigns a session per request:

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

Use a fresh transport per request, which stateless mode requires anyway. With @rekog/mcp-nest, set the same option on the module: streamableHttp: { statelessMode: true, enableJsonResponse: true }.

If you must stream (SSE)

Set the header yourself at the HTTP layer with encodeSessionId, reading clientInfo off the initialize body. The SDK decodes it either way:

TypeScript
import { MCP_SESSION_HEADER, encodeSessionId, newSessionId } from "@posthog/mcp"
// after parsing the POST body, before flushing response headers:
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,
})
)
}

When you can't use a session token

Some frameworks create the transport without exposing enableJsonResponse. Some clients ignore the session header. Either case can produce a separate session per request.

Use identify to group calls by distinct_id without client cooperation. Conversation IDs group calls by conversation when the agent echoes the handle.

Python

The Python MCP Analytics SDK is part of posthog, like posthog.ai. Install the package:

Terminal
pip install posthog

instrument() requires mcp or fastmcp at runtime. These are peer dependencies, not bundled packages. The SDK detects both official mcp majors (mcp>=1.26,<3) and supports jlowin's standalone fastmcp. Custom dispatchers that use PostHogMCP require only posthog.

instrument(server, posthog_client, options?) works with every common Python MCP server:

Python
from posthog import Posthog
from posthog.mcp import instrument
from mcp.server.fastmcp import FastMCP
posthog = Posthog(
"phc_your_project_api_key",
host="https://us.i.posthog.com", # or https://eu.i.posthog.com
)
server = FastMCP("my-server")
# On MCP SDK 2.x, FastMCP was renamed — instrument() works the same:
# from mcp.server.mcpserver import MCPServer
# server = MCPServer("my-server")
# register your tools as usual...
analytics = instrument(server, posthog)

Options are passed as MCPAnalyticsOptions, the snake_case equivalent of the TypeScript options:

Python
from posthog.mcp import instrument
from posthog.mcp.types import MCPAnalyticsOptions, UserIdentity
instrument(server, posthog, MCPAnalyticsOptions(
report_missing=True, # register the get_more_tools virtual tool
identify=lambda request, extra: UserIdentity(distinct_id="user_123"),
))

MCPAnalyticsOptions fields (the TypeScript Configuration table below uses camelCase – these are the Python names):

OptionTypeDefaultWhat it does
contextbool \| MCPAnalyticsContextOptionsTrueInject the context intent argument into compatible tool schemas.
report_missingboolFalseRegister the get_more_tools virtual tool.
missing_capability_tool_namestr"get_more_tools"Rename the virtual tool registered by report_missing.
enable_conversation_idboolTrueInject an optional conversation_id argument to group calls when the agent echoes the handle.
capture_modelbool \| MCPAnalyticsModelOptionsTrueCapture the model from recognized client metadata or an SDK-injected llm_model argument.
enable_exception_autocaptureboolTrueEmit a $exception sibling on failed tool calls.
identify(request, extra) -> UserIdentity \| None (sync or async)Map a request to one of your users.
intent_fallback(request, extra) -> str \| NoneProvide intent when the agent didn't pass context.
before_send(event) -> event \| NoneInspect/modify/drop each event before send.
event_properties(request, extra) -> dictProperties merged onto every event.
logger(message: str) -> Noneno-opSTDIO-safe log sink.

Stateless and multi-pod servers

A stateless Python deployment has the same correlation problem. The SDK creates a session token in the Mcp-Session-Id header. Clients resend it on later requests. The ASGI layer supports JSON and SSE, so Python does not need enableJsonResponse.

On official mcp.server.fastmcp and jlowin's fastmcp 2.0, instrument() wraps the streamable_http_app() and sse_app() factories. These factories also serve run(). Set the server to stateless mode:

Python
server = FastMCP("my-server", stateless_http=True)
instrument(server, posthog)
server.run(transport="streamable-http") # or: app = server.streamable_http_app()

When you build the ASGI app yourself – a low-level Server, or a custom PostHogMCP dispatcher – add the middleware to that app once:

Python
from posthog.mcp import PostHogMcpStatelessSessionMiddleware
app.add_middleware(PostHogMcpStatelessSessionMiddleware)

Flushing on exit

The posthog client batches events asynchronously. You manage its lifecycle. instrument() schedules captured events in the background.

At shutdown, call await analytics.flush() to wait for pending captures. Then call posthog.shutdown() to send queued events and stop the client. posthog.flush() sends queued events without stopping it. See the complete Python example:

Python
analytics = instrument(server, posthog)
# ... serve ...
await analytics.flush() # drain in-flight auto-capture events
posthog.shutdown() # flush + stop the posthog client

Custom dispatchers (no server object to wrap)

For a custom dispatcher, use PostHogMCP, a posthog client subclass that does not require an MCP server object. Call its capture methods to create events. Use prepare_tool_list() and prepare_tool_call() for intent capture. It uses the same events, redaction, and truncation as instrument(). See the custom dispatcher example.

Python SDK is beta

Python MCP Analytics is in beta, so its API may change. See the event reference for SDK coverage.

It supports both official mcp majors and the 2026-07-28 protocol revision. See MCP SDK v2 for what that revision changes.

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:

OptionTypeDefaultWhat it does
logger(message: string) => voidno-opSTDIO-safe log sink for SDK-internal warnings. MCP STDIO transports cannot use console.*, so the default discards. Configure a logger to see warnings during development.
enableExceptionAutocapturebooleantrueWhen false, a failed tool call does not emit the $exception sibling event.
contextboolean \| { description: string }trueInject a required context argument into compatible tool schemas. See Capturing agent intent.
captureModelboolean \| { description: string }trueCapture the model from recognized client metadata or an SDK-injected llm_model argument.
intentFallback(request, extra) => string \| Promise<string \| null \| undefined>Called when the agent didn't pass a context argument. See Capturing agent intent.
enableConversationIdbooleantrueInject an optional conversation_id argument into compatible tool schemas. See Conversation IDs.
reportMissingbooleanfalseRegister the get_more_tools virtual tool. See Missing capability.
identifyasync (request, extra) => UserIdentity \| null \| UserIdentityMap 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.
eventPropertiesasync (request, extra) => Record<string, unknown>Properties merged onto every event. See Custom events and metadata.

Capture the calling model

Model capture is enabled by default in both SDKs. The SDK reads recognized client metadata first, then falls back to the agent's llm_model argument. Events include $mcp_llm_model and $mcp_llm_model_source ("client_metadata" or "self_reported").

The recognized metadata field is Codex's params._meta["x-codex-turn-metadata"].model. Other clients can provide the llm_model argument.

Use this unverified client input to compare tool quality, latency, and errors by model. Don't use it for billing or security decisions. Missing, blank, and unknown values are omitted. Reasoning effort isn't captured.

To disable model capture, conversation IDs, or both, set the corresponding options to false:

instrument(server, posthog, {
captureModel: false,
enableConversationId: false,
})
Schema and framework compatibility

For compatible tool schemas, TypeScript advertises llm_model as required. Python does the same on official high-level adapters and custom dispatchers, but makes it optional on raw low-level servers and standalone FastMCP. Dispatch never enforces the injected field.

The SDK removes arguments it can confirm it injected before your handler runs. A fresh low-level instance that hasn't served tools/list can read llm_model, but leaves arguments untouched. It can capture an application-owned llm_model until it learns the tool's schema. High-level adapters use the registered schema to preserve application-owned arguments.

On Python's standalone FastMCP with MCP SDK 1.x, middleware overrides of tool-listing or dispatch hooks disable llm_model injection. This also applies to middleware that only passes requests through. Capture from recognized client metadata still works.

Model capture works on both protocol revisions. See MCP SDK v2 for MCP Apps coverage.

Graceful shutdown

The posthog-node client queues and batches events asynchronously. You manage its lifecycle. Call posthog.shutdown() from your SIGTERM or beforeExit handler to send queued events:

TypeScript
import { PostHog } from "posthog-node"
import { instrument } from "@posthog/mcp"
const posthog = new PostHog(process.env.POSTHOG_PROJECT_TOKEN)
instrument(server, posthog)
process.on("SIGTERM", async () => {
await posthog.shutdown()
process.exit(0)
})

Call posthog.flush() to send queued events without stopping the client.

In serverless or edge environments, flush at the end of each invocation because SIGTERM may not run. Use await posthog.flush(). On supported platforms, use ctx.waitUntil(posthog.flush()).

What happens after install

As soon as the wrapper is in place, instrumented MCP requests emit PostHog events:

  • $mcp_tool_call per tool invocation
  • $mcp_tools_list per tools/list response
  • $mcp_initialize per 2025-11-25 client handshake
  • $exception whenever a tool throws or returns isError: true

Both SDKs also capture resource discovery and reads. Resource bodies pass through unchanged and aren't captured. See the event reference for SDK coverage. Prompt requests don't emit automatic analytics yet.

On 2025-11-25, calls keep their MCP protocol session until the agent echoes a valid conversation handle. On 2026-07-28, there is no protocol session. Conversation IDs are enabled by default, but calls only stay correlated when the agent echoes the handle. See the event reference for the full catalog.

Still have questions?

Was this page useful?