Installing the MCP analytics SDK

Beta SDK

@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 (TypeScript/JavaScript), or Python 3.10+ — see Python below
  • An MCP server built on @modelcontextprotocol/sdk (TS) or the mcp package (Python). (Running a custom dispatcher with no server object to wrap? See Custom servers.)
  • A PostHog project API key (phc_…)

Install

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

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:

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_API_KEY, {
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

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:

TypeScript
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_API_KEY, {
host: "https://us.i.posthog.com",
})
const analytics = instrument(server, posthog)
server.tool("search_events", { /* ... */ }, async (args) => {
// your handler runs untouched
})

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:

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_API_KEY, {
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'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:

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

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.

NestJS (@rekog/mcp-nest)

With @rekog/mcp-nest you don't construct the server yourself — McpModule.forRoot(...) does, and you define tools with @Tool() decorators. Instrument it through the module's serverMutator hook using instrumentMutator, which returns the server for you:

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_API_KEY, {
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) is shorthand for (server) => { instrument(server, posthog); return server }. It returns the server, not the analytics handle, so it slots straight into serverMutator. The tools mcp-nest registers after the mutator runs are still captured.

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
}

Python

A Python SDK ships inside the posthog package (the same way posthog.ai does), so there's nothing extra to install:

Terminal
pip install posthog

instrument() needs the MCP SDK at runtime, but you already have it — you built your server with mcp or fastmcp, so it's treated as a peer dependency rather than bundled. (PostHogMCP for custom dispatchers needs nothing beyond 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")
# 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(
context=True, # inject the `context` intent argument (default)
report_missing=True, # register the get_more_tools virtual tool
enable_conversation_id=True, # stitch calls across reconnects
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 every tool.
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_idboolFalseInject an optional conversation_id argument to stitch calls.
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.

Flushing on exit

The posthog client batches events asynchronously and you own its lifecycle. On the instrument() path, auto-captured events are scheduled in the background — await analytics.flush() waits for in-flight events, then posthog.flush() / posthog.shutdown() sends them. Call this from your shutdown/SIGTERM handler so trailing events aren't dropped (see examples/mcp_analytics_demo.py for a runnable end-to-end example):

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

No server object to wrap (a custom HTTP/edge dispatcher)? Use PostHogMCP, a posthog client subclass (needs nothing beyond posthog — no MCP SDK) with capture_tool_call(), capture_initialize(), prepare_tool_list(), and prepare_tool_call() — the Python equivalent of Custom servers.

Python SDK is beta

The Python SDK is in beta (pre-1.0); the API may still change before v1, and some TypeScript-only features may land first. It emits the identical $mcp_* events documented on the events page.

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. Wire your own to surface 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 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.
enableConversationIdbooleanfalseInject an optional conversation_id argument into every tool. 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.

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:

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

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_call per tool invocation
  • $mcp_tools_list per tools/list response
  • $mcp_initialize per client handshake
  • $mcp_resource_read, $mcp_resources_list, $mcp_prompt_get, $mcp_prompts_list as applicable
  • $exception whenever a tool throws or returns isError: 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.

Community questions

Was this page useful?

Questions about this page? or post a community question.