# PostHog MCP package

Please see the main [PostHog docs](https://posthog.com/docs).

SDK usage examples and code snippets live in the official documentation so they stay up to date.

## Documentation

- [MCP analytics docs](https://posthog.com/docs/mcp-analytics)

## Stateless & multi-pod servers

On stateless deployments the SDK mints the `Mcp-Session-Id` response header at `initialize`
as a token carrying the session id and client name/version. Clients replay the header on
every request, so any pod keeps `$session_id` and `$mcp_client_name`/`$mcp_client_version`
stable with no server-side store.

The SDK publishes no standalone `$identify` event. The `identify` callback runs on every request,
on any pod, and stamps `distinct_id` and `$set` onto each event it sends.

### Streamable HTTP: set `enableJsonResponse: true`

The token is minted onto the `Mcp-Session-Id` **response** header from inside the `initialize`
handler, so it only reaches the client when the transport builds the response **after** the
handler runs — i.e. **JSON mode**. In **SSE (streaming) mode** `StreamableHTTPServerTransport`
flushes the response headers **before** the handler runs, so the minted header never lands and
behavior silently falls back to a session-per-request. This is a property of the transport, so
it applies to **every** Streamable-HTTP host — set `enableJsonResponse: true` (and use a fresh
transport per request):

```ts
// @modelcontextprotocol/sdk
new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, enableJsonResponse: true })

// Cloudflare agents / createMcpHandler (SSE is the default)
createMcpHandler(server, { enableJsonResponse: true })

// @rekog/mcp-nest
McpModule.forRoot({ streamableHttp: { enableJsonResponse: true } })
```

### If you must stream (SSE)

Set the header yourself at the HTTP layer with the exported `encodeSessionId` (read `clientInfo`
from the `initialize` body) — the SDK decodes it either way:

```ts
import { MCP_SESSION_HEADER, encodeSessionId, newSessionId } from '@posthog/mcp'

// after parsing the POST body, before flushing 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,
    })
  )
}
```

Details: [docs/ARCHITECTURE.md §4](./docs/ARCHITECTURE.md).

## MCP TypeScript SDK v2

`instrument()` works on both SDK majors — `@modelcontextprotocol/sdk` v1 and
`@modelcontextprotocol/{core,server,client}` v2 — and on both the high-level `McpServer` and the
low-level `Server`. Shapes are detected at runtime, so neither major is a dependency here.

### Sessions and client identity on a v2 server

Protocol revision is a property of each **request**, not of the server: a v2 server serves both
`2025-11-25` and `2026-07-28` traffic, and the SDK is instrumented once for both.

- **On `2026-07-28`** there is no `initialize` and no session header — the revision removed
  protocol-level sessions, and this SDK will not mint one. Session correlation therefore comes from
  `enableConversationId`, which is **on by default**. Without it every request is its own `$session_id`.
  The `get_more_tools` and `send_feedback` virtual tools also use this handle, including calls handled
  by a fresh server instance.
- **On `2025-11-25`**, the session id and the client's name and version are exchanged once at
  `initialize`. If your server builds a fresh `McpServer` per HTTP request — which
  `createMcpHandler` does by default — the instance serving a later `tools/call` never saw that
  handshake. The SDK bridges it by minting the `Mcp-Session-Id` token described above, which the
  client replays on every request.

  **That token only reaches the client if the transport builds response headers _after_ the handler
  runs.** `@rekog/mcp-nest` with `enableJsonResponse: true` does; `createMcpHandler`'s legacy leg
  does not, and there is no setting we can reach from inside the server. On that leg, expect
  `$mcp_client_name` and `$mcp_client_version` to be absent for `2025-11-25` traffic — the protocol
  version still arrives, because clients send it on the `MCP-Protocol-Version` header of every
  request.

### Reading request headers in a callback

If your `identify`, `intentFallback`, `eventProperties` or `beforeSend` reads HTTP headers, it has
to change. The two majors put the request in different places and in different shapes: v1 attaches
a plain object at `extra.requestInfo.headers`, v2 attaches the WHATWG `Request` at `extra.http.req`,
whose `headers` only answers to `.get()`. A v1-shaped read returns `undefined` on v2 — an
`identify()` written that way returns `null` and every event goes anonymous.

The SDK hands your callback whatever the MCP SDK handed it, unchanged; it does **not** fake a v1
shape on v2, because a partially synthesised `requestInfo` is a more convincing lie than an absent
one. Read headers through the exported helper instead, which handles both majors, lowercases keys,
and duck-types `Headers` so it also works on edge runtimes:

```ts
import { getRequestHeaders } from '@posthog/mcp'

identify: async (request, extra) => {
  const auth = getRequestHeaders(extra)?.['authorization'] // v1 and v2
  // ...
}
```

### What `$mcp_intent` records, and how to turn it off

`context` defaults to **on**: the SDK adds a `context` parameter to every tool it advertises, asks
the agent to say why it is calling, and records the answer as `$mcp_intent`. It is stripped before
your tool runs wherever the SDK can confirm the parameter is its own.

On a server that builds a fresh instance per HTTP request — `createMcpHandler`, or `@rekog/mcp-nest`
in its stateless mode — that confirmation is not available: ownership is learned while serving
`tools/list`, and the instance handling a `tools/call` never served one. There the SDK records the
argument but does **not** strip it, because deleting an argument your tool declared would cost you
the call, while an extra key usually costs nothing.

What matters is instance lifetime, not statelessness. A server that is stateless at the transport
(`sessionIdGenerator: undefined`) but keeps one long-lived server object learns ownership from the
first `tools/list` and keeps it, so none of the above applies to it.

An extra key is not free for a tool that validates strictly (a `.strict()` Zod object, or
`additionalProperties: false` enforced by the handler): it rejects the call. A low-level server that
can look up its own tools can resolve ownership without a listing. Return the tool's input schema
as registered, before PostHog preparation, and the SDK strips the arguments it owns on every
instance. It never strips a parameter that the returned schema declares. Returning `undefined`
keeps the behavior above.

```ts
instrument(server, posthog, {
  resolveOriginalTool: (toolName) => {
    const tool = myToolRegistry.get(toolName)
    return tool ? { inputSchema: tool.inputSchema } : undefined
  },
})
```

The consequence worth knowing: if **your own** tool declares a parameter named `context` and the SDK
cannot tell that it is yours, its value is recorded as `$mcp_intent`. It never leaves your project,
and it is capped at 2048 characters. Two ways out, both one line:

```ts
instrument(server, posthog, { context: false }) // no injection, no capture

instrument(server, posthog, {
  // keep it, drop the property
  beforeSend: (event) => {
    delete event.properties.$mcp_intent
    return event
  },
})
```

`intentFallback` is the third option: supply the intent yourself when the agent did not send one.

Whatever the agent narrates, the SDK redacts structured personal identifiers — email addresses, phone
numbers, IPv4/IPv6 addresses, Luhn-valid card numbers, and US SSNs — from `$mcp_intent` before it is
sent. This is always on and needs no configuration. It is best-effort for those well-defined shapes,
not for free-form personal data such as names or postal addresses, which a regex cannot catch without
over-redacting ordinary prose. It also applies only to `$mcp_intent`: structured tool `arguments` and
results are left as-is, because the same shapes are often legitimate data there. If you need a stronger
guarantee, `context: false` and the `beforeSend` hook above remain the ways to drop the field entirely.

### Defaults and opt-outs

On fresh low-level instances, tools with an `outputSchema` deliver new handles through `content` only. Clients that consume only `structuredContent` will not echo those handles, so correlation is not guaranteed for that combination. Unknown ownership can also cause a prompt-back block to be appended for a schema that discovery would not extend; disable `enableConversationId` if that contract is unsuitable.

A valid echoed conversation handle takes precedence over transport sessions. A request that already carries a transport session or a PostHog session token does not mint a new handle or append a prompt-back block. Requests carrying neither use the conversation fallback.

Intent, model capture, conversation correlation, and exception capture are enabled by default.
Missing-capability reporting and feedback collection remain disabled.

```ts
instrument(server, posthog, { captureModel: false, enableConversationId: false })
```

Model capture adds a required `llm_model` argument to compatible tool schemas. Dispatch never
enforces it, so servers keep working; strict-schema clients see the new field. Set
`captureModel: false` to leave schemas untouched. Conversation correlation adds an optional
`conversation_id` argument and returns a handle in eligible tool results. Clients must echo that
handle to group later calls; calls without it mint new handles. Set `enableConversationId: false`
to retain transport-based session grouping and unchanged response content. Custom `PostHogMCP`
dispatchers also enable model capture and conversation correlation by default.
The reasoning behind these defaults is in [ADR-0013](./docs/adr/0013-analytics-capture-is-on-by-default.md).

### Identifying the deployed server build

MCP advertises a server version, but it does not define an exact build identifier. Pass an immutable
Git commit SHA, release ID, or container digest once during setup. The SDK adds it to every MCP event
as `$mcp_server_build`.

```ts
instrument(server, posthog, { serverBuild: process.env.GIT_SHA })

const customDispatcherClient = new PostHogMCP(process.env.POSTHOG_PROJECT_TOKEN, {
  serverBuild: process.env.GIT_SHA,
})
```

Omit `serverBuild` when the deployment does not provide a reliable value.
`PostHogMCP` also adds the build to custom events from its inherited `capture()` and `captureImmediate()` methods.

### What `$mcp_llm_model` records, and when it stays empty

`captureModel` is **on** by default. The SDK records the best model id visible to the
server as `$mcp_llm_model`. Recognized client metadata wins with source `client_metadata`. Otherwise,
the SDK injects a required `llm_model` parameter and records the answer with source `self_reported`.

MCP does not standardize or attest model identity. Client metadata and self-report are both
unverified. Use the value to spot degradation across models, never for billing or access control.
Missing, blank, and `unknown` values are recorded as nothing.

The recognized metadata path is Codex's `x-codex-turn-metadata.model` field inside request `_meta`.
Other clients keep using self-report until they expose a stable model field.

Like `context`, self-report follows the ownership rule of ADR-0011: reading `llm_model` fails
open where the SDK cannot tell who declared it, stripping it requires proof that the SDK did:

- `instrument(server)` on a high-level `McpServer` resolves ownership for your registered tools per
  request from the live tool registry, so those work even on a fresh instance.
- The `get_more_tools` virtual tool works on any instance and on either server type: the SDK writes
  that descriptor itself, so what it declares is known without a listing.
- Instrumenting a low-level `Server` learns ownership while serving `tools/list`. A fresh instance
  that never served one — `createMcpHandler`, or `@rekog/mcp-nest` in its stateless mode — has no
  answer, so it records `llm_model` as the self-reported model and strips nothing, unless
  `resolveOriginalTool` supplies the tool's schema (see `$mcp_intent` above). Return the schema as
  your `tools/list` advertises it: ownership follows the same rule as a served listing, and a
  listing served on the instance wins. A Zod schema is read the way the MCP SDK advertises it, so a
  union, record or refined object lists as an empty object, gains the injected fields and has them
  stripped; a host that lists its own JSON Schema (an `anyOf`, say) returns that schema. A tool that
  declares its own `llm_model` on such an instance is therefore recorded under `$mcp_llm_model`
  until a listing says otherwise; `captureModel: false` or dropping the property in `beforeSend`
  are the escapes. The SDK never replays your listing handler on the call path to find out.

As with `context`, what matters is instance lifetime rather than statelessness: a transport-stateless
server (`sessionIdGenerator: undefined`) that keeps one long-lived server object learns ownership
from the first `tools/list` and keeps it.

For a custom dispatcher, `PostHogMCP` enables model capture and conversation correlation by default.
`prepareToolList()` injects the analytics fields and records ownership by tool name.
`prepareToolCall()` removes SDK-owned arguments and resolves the conversation and session.
`prepareToolResult()` returns the result to send and the final values to capture. Pass the original
tool descriptor on each call so this also works when `tools/list` and `tools/call` reach different
server replicas:

```ts
const posthog = new PostHogMCP(process.env.POSTHOG_PROJECT_TOKEN)

const tools = posthog.prepareToolList(serverTools)
const originalTool = serverTools.find((tool) => tool.name === toolName)
const preparedCall = posthog.prepareToolCall(toolName, rawArgs, {
  originalTool,
  requestMeta: request.params?._meta,
  sessionId: transportSessionId,
})
const toolResult = await dispatch(toolName, preparedCall.args)
const preparedResult = posthog.prepareToolResult(toolResult, preparedCall)

posthog.captureToolCall({
  toolName,
  llmModel: preparedCall.llmModel,
  llmModelSource: preparedCall.llmModelSource,
  sessionId: preparedResult.sessionId,
  conversationId: preparedResult.conversationId,
  isError: false,
})

return preparedResult.result
```

A persistent single-process dispatcher can omit `originalTool` after it has prepared its tool list.
Pass `requestMeta` whenever the request supplies `_meta`; this enables recognized client metadata
without changing the arguments sent to the tool. Pass an existing transport or request session as
`sessionId`. A valid echoed conversation handle takes precedence. Otherwise, the existing session
prevents a new handle from being minted. Set `enableConversationId: false` on `PostHogMCP` to keep
the previous custom-dispatcher behavior.

### Collecting agent feedback (`send_feedback`)

`collectFeedback` is **off** by default. Turn it on and the SDK advertises a `send_feedback` virtual
tool: an honest, general feedback channel from the agent to your team. The tool description makes
missing capabilities the priority category ("report a missing capability whenever no available tool
fits your task, even if you can work around it") and also invites reports about a tool that failed
or confused the agent, and praise. Every call emits one `$mcp_feedback` event — never a
`$mcp_tool_call` — with `$mcp_feedback_type`, `$mcp_feedback_summary`, and the other
`$mcp_feedback_*` properties; the summary and details double as `$mcp_intent`. The agent receives an
acknowledgement that says exactly what happened: the report was recorded, no tools were added.

```ts
instrument(server, posthog, {
  collectFeedback: {
    // All fields optional; `collectFeedback: true` uses the defaults.
    toolName: 'send_feedback',
    extraProperties: {
      product_area: { type: 'string', description: 'The product or feature the feedback is about.' },
    },
    extraRequired: ['product_area'],
    onFeedback: async (report) => {
      await myFeedbackBackend.record(report) // report.extras.product_area, report.raw, ...
      return 'Thanks - your feedback reached the team.' // replaces the default acknowledgement
    },
  },
})
```

Host-declared `extraProperties` are merged into the advertised schema and captured as
`$mcp_feedback_<key>` (sanitized and bounded like every captured value). Arguments the agent invents
beyond the schema are never captured — they reach `onFeedback` via `report.raw` only. A key that
collides with a core field fails at configuration time. Free-text fields go through the same PII
redaction as `$mcp_intent`.

`onFeedback`'s returned string replaces the acknowledgement and is captured as `$mcp_response`
through the generic sanitize pipeline only — it does not get the structured-PII redaction applied to
`$mcp_feedback_summary`/`details`, so don't echo the agent's raw report text back in it.

On the custom-dispatcher path, configure the tool on the `PostHogMCP` constructor and route reports
yourself (`onFeedback` does not apply there):

```ts
const posthog = new PostHogMCP(process.env.POSTHOG_PROJECT_TOKEN, {
  collectFeedback: { toolName: 'send_feedback' },
})

// tools/list handler
return { tools: posthog.prepareToolList(myTools, { collectFeedback: true }) }

// tools/call dispatcher
const originalTool = myTools.find((tool) => tool.name === name)
const preparedCall = posthog.prepareToolCall(name, rawArgs, { originalTool })
if (preparedCall.isFeedback) {
  await myFeedbackBackend.record(preparedCall.feedbackReport!)
  const preparedResult = posthog.prepareToolResult(sendFeedbackResult(), preparedCall)
  posthog.captureFeedback({
    report: preparedCall.feedbackReport!,
    sessionId: preparedResult.sessionId,
    conversationId: preparedResult.conversationId,
    ...identity,
  })
  return preparedResult.result
}
```

`originalTool` must come from the application's tool list before `prepareToolList()` adds PostHog's
virtual tools. This lets a real application tool with the configured feedback name win, including
when `tools/list` and `tools/call` reach different server replicas.

`send_feedback` covers what `reportMissing` covers — a capability gap is
`feedback_type: "missing_capability"` — so new integrations should enable only `collectFeedback`.
`reportMissing` and its `$mcp_missing_capability` event stay unchanged for existing users; enabling
both advertises both tools. Like `get_more_tools`, a real tool that already uses the configured name
wins: the SDK warns, skips injection, and delegates calls to the real handler.

On a paginated catalogue (a `tools/list` response with a `nextCursor`), `instrument()` injects
its virtual tools (`send_feedback` and `get_more_tools`) on the first page only — the page every
client reads, including clients that never follow `nextCursor` — so a compliant client's
concatenated list carries each once. "First page" means a `tools/list` request with no cursor; an
empty string is a valid cursor, so `cursor: ""` is a continuation page. Hosts using
`prepareToolList()` directly own this rule themselves: pass `reportMissing: true` and
`collectFeedback: true` only for the first page.

Name collisions are detected on the first page only. A real tool named `send_feedback` (or
`get_more_tools`) on the first page wins: the SDK warns, skips injection, and forwards its calls. A
real tool that only appears on a **later** page is not detected up front — the SDK's virtual tool is
injected and intercepts calls to the name, so the real tool is shadowed and a concatenated listing
carries the name twice. The SDK logs a warning when a client fetches the colliding page, but the fix
is yours: rename the SDK's tools with `collectFeedback: { toolName: "..." }` and the
`missingCapabilityToolName` option.

### If you switched to `instrument(server.server)`

Before v2 support landed, the compatibility gate rejected high-level v2 servers, and the usual
workaround was to instrument the underlying low-level server. `instrument(server)` now works, so
you can go back to the documented call.

Whether the workaround costs you anything depends on your stack: instrumenting the low-level server
skips the high-level tool registry, so tool descriptions and callback-level wrapping come only from
what is advertised over `tools/list`. If your framework never calls `registerTool()` — `@rekog/mcp-nest`
does not — the registry is empty and the two calls behave the same.

## Developing locally

To test local changes in a consumer app (e.g. a dummy MCP server), symlink **both**
`@posthog/mcp` and its `posthog-node` peer from this monorepo into the app — run from the
app's directory:

```bash
mkdir -p node_modules/@posthog   # in case the app has no other @posthog/-scoped deps yet
ln -s /absolute/path/to/posthog-js/packages/mcp  node_modules/@posthog/mcp
ln -s /absolute/path/to/posthog-js/packages/node node_modules/posthog-node
```

Then keep a watch build running and restart the app after each change:

```bash
cd /absolute/path/to/posthog-js/packages/mcp && pnpm dev   # rebuilds dist/ on save

# in the app (e.g. dummy mcp), after each rebuild:
npm start                                                  # Node caches dist/ at startup, so restart to pick it up
```

- **`npm install` in the app replaces both symlinks with published copies** — re-create them if you run it.

## Run tests

```bash
cd packages/mcp && pnpm test:unit
```
