Installing the MCP Analytics SDK
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.
Requirements
- Node.js 20.20+ or 22.22+ (TypeScript/JavaScript), Python 3.10+ – see Python below – or Ruby 3.0+ – see Ruby below (experimental and unsupported)
- An MCP server built on either TypeScript SDK major –
@modelcontextprotocol/sdk(v1) or@modelcontextprotocol/{core,server,client}(v2) – either official Python MCP SDK major (mcp>=1.26,<3), or the official Ruby MCP SDK (mcpgem>= 1.4, experimental and unsupported). jlowin's standalonefastmcppackage 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:
For manual installation, follow the steps below.
Install
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:
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:
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:
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:
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:
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:
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.
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:
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:
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:
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:
FastMCPand the low-levelServerfrom the officialmodelcontextprotocol/python-sdk(themcppackage, 1.x)MCPServer– FastMCP's new name onmcp2.x – and the v2 low-levelServer, see MCP SDK v2- jlowin's standalone FastMCP 2.0 (the separate
fastmcppackage) PostHogMCPfor custom dispatchers with no server object (see below)
Options are passed as MCPAnalyticsOptions, the snake_case equivalent of the TypeScript options:
MCPAnalyticsOptions fields (the TypeScript Configuration table below uses camelCase – these are the Python names):
| Option | Type | Default | What it does |
|---|---|---|---|
context | bool \| MCPAnalyticsContextOptions | True | Inject the context intent argument into compatible tool schemas. |
report_missing | bool | False | Register the get_more_tools virtual tool. |
missing_capability_tool_name | str | "get_more_tools" | Rename the virtual tool registered by report_missing. |
enable_conversation_id | bool | True | Inject an optional conversation_id argument to group calls when the agent echoes the handle. |
capture_model | bool \| MCPAnalyticsModelOptions | True | Capture the model from recognized client metadata or an SDK-injected llm_model argument. |
enable_exception_autocapture | bool | True | Emit 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 \| None | – | Provide intent when the agent didn't pass context. |
before_send | (event) -> event \| None | – | Inspect/modify/drop each event before send. |
event_properties | (request, extra) -> dict | – | Properties merged onto every event. |
logger | (message: str) -> None | no-op | STDIO-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:
When you build the ASGI app yourself – a low-level Server, or a custom PostHogMCP dispatcher – add the middleware to that app once:
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:
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 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.
Ruby
PostHog::MCP is experimental and not officially supported. We don't provide support for it, and the MCP analytics team doesn't maintain it. Its API, its options, and the $mcp_* events it captures may change in a minor posthog-ruby release, and the gem logs a warning when you require it.
Try it, and report bugs or send patches to posthog-ruby – but don't build production reporting on it yet. For a supported SDK, use TypeScript or Python.
A Ruby SDK ships inside the posthog-ruby gem, so there's nothing extra to install:
PostHog::MCP.instrument needs the official Ruby MCP SDK at runtime, but you already have it – you built your server with the mcp gem (>= 1.4) – so it's treated as a peer dependency rather than bundled. (PostHog::MCP::Client for custom dispatchers needs nothing beyond posthog-ruby.)
PostHog::MCP.instrument(server, client, **options) wraps an MCP::Server, whether you register tools as MCP::Tool classes or with define_tool, and works over the stdio and Streamable HTTP transports:
If your app already uses posthog-rails and calls PostHog.init, leave the client out and the SDK picks up PostHog.client for you:
Options are keyword arguments:
| Option | Type | Default | What it does |
|---|---|---|---|
context | Boolean \| { description: } | true | Inject the context intent argument into every tool. |
report_missing | Boolean | false | Advertise the get_more_tools virtual tool. |
missing_capability_tool_name | String | "get_more_tools" | Rename the virtual tool registered by report_missing. |
enable_conversation_id | Boolean | false | Inject an optional conversation_id argument to stitch calls. |
enable_exception_autocapture | Boolean | true | Emit a $exception sibling on failed calls. |
capture_model | Boolean \| { description: } | false | Inject llm_model and capture $mcp_llm_model. |
identify | (request, extra) -> Hash \| nil, or a static Hash | – | Map a request to one of your users (distinct_id:, properties:, groups:). |
intent_fallback | (request, extra) -> String \| nil | – | Provide intent when the agent didn't pass context. |
before_send | (payload) -> payload \| nil | – | Inspect, modify, or drop each event before send. |
event_properties | (request, extra) -> Hash | – | Properties merged onto every event. |
logger | ->(message) { ... } | no-op | STDIO-safe log sink. Never writes to stdout. |
The injected arguments are stripped before your tool's call receives its keywords, so a tool declared as def self.call(query:, server_context:) keeps working. A tool that declares context in its own input_schema keeps it. A tool whose input_schema is composed (oneOf, allOf, anyOf) or a $ref gets no injected arguments.
Events are truncated to fit the 32 KB message limit of the posthog-ruby client, because the client drops larger messages when it sends a batch.
instrument returns an analytics handle for custom events: analytics.capture("feedback_submitted", { rating: 5 }). Call it from the tool body. On Ruby 3.2+ it also works from a thread or fiber that the tool starts. On Ruby 3.0 and 3.1 such an event gets its own $session_id on HTTP servers.
Prompt and resource traffic is captured too, as $mcp_prompt_get, $mcp_prompts_list, $mcp_resource_read, and $mcp_resources_list.
$lib on Ruby events
MCP events report $lib: "posthog-ruby-mcp" so you can tell them apart from the rest of your traffic. This is set per event: the client you pass in keeps its own $lib (posthog-ruby or posthog-rails) for everything else it sends, so instrumenting an MCP server inside a Rails app doesn't relabel the app's other events.
Stateless and multi-pod servers
A stateless server keeps nothing between requests, often on a different pod each time. Left alone, every request becomes its own $session_id, and the client name and version (only sent at initialize) go missing from every event after the handshake.
The SDK handles this with no session store and no sticky routing. When MCP::Server::Transports::StreamableHTTPTransport runs with stateless: true, the SDK mints the Mcp-Session-Id response header at initialize as a token carrying the session ID and client identity. Clients replay that header on every request, so any pod reads the same values back. Nothing changes on the client side, and there's nothing to configure.
When you build the Rack app yourself, add the middleware once:
It reads neither the request nor the response body: it publishes the request's headers to the instrumented server below it and carries back the token that server minted once the handshake succeeded. The decoded token is exposed to your app as env["posthog_mcp.session"]. (Dispatching MCP requests by hand, with no MCP::Server to wrap? Then you mint it yourself – see Custom servers.)
Stateful HTTP servers need nothing: the transport's own session ID is hashed deterministically, so a session survives restarts. Conversation IDs work too and need no middleware at all.
Flushing on exit
Captured events go straight into the posthog-ruby client's queue, so there's nothing to drain besides the client itself. Call posthog.flush or posthog.shutdown when your process stops:
Logging on stdio servers
A stdio MCP server owns $stdout for the protocol. The integration's own messages go only to the logger: you pass (nowhere by default); the experimental notice and misconfiguration warnings go to stderr. Point the core SDK's logger away from stdout too:
Dispatching MCP requests without an MCP::Server? See Custom servers.
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:
| Option | Type | Default | What it does |
|---|---|---|---|
logger | (message: string) => void | no-op | STDIO-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. |
enableExceptionAutocapture | boolean | true | When false, a failed tool call does not emit the $exception sibling event. |
context | boolean \| { description: string } | true | Inject a required context argument into compatible tool schemas. See Capturing agent intent. |
captureModel | boolean \| { description: string } | true | Capture 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. |
enableConversationId | boolean | true | Inject an optional conversation_id argument into compatible tool schemas. See Conversation IDs. |
reportMissing | boolean | false | Register the get_more_tools virtual tool. See Missing capability. |
identify | async (request, extra) => UserIdentity \| null \| UserIdentity | – | Map 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. |
eventProperties | async (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:
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:
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_callper tool invocation$mcp_tools_listpertools/listresponse$mcp_initializeper2025-11-25client handshake$exceptionwhenever a tool throws or returnsisError: 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.