# MCP Integration Architecture This directory contains PostHog's MCP (Model Context Protocol) server. The protocol is served by the **Hono runtime** (Node, deployed to our k8s clusters). A thin **Cloudflare Worker** sits in front of it as a stateless edge router that terminates OAuth, validates tokens, and proxies `/mcp` traffic to the regional Hono deployment. ## Overview ```mermaid flowchart TB subgraph Worker["Cloudflare Worker (index.ts)"] W1["Stateless - new instance per request"] W2["Handles OAuth metadata endpoints (RFC 8414, RFC 9728)"] W3["Validates tokens and resolves the user's region"] W4["Wide logging via middleware"] end subgraph Hono["Hono runtime (Node / k8s)"] H1["Serves the MCP protocol (tools, prompts, resources)"] H2["Per-user session state in Redis (keyed by token hash)"] H3["Tracks analytics events to PostHog"] end Worker -->|"proxyToHono(): forwards /mcp to
mcp.{us,eu}.posthog.com"| Hono ``` The Worker no longer serves the protocol itself — an earlier iteration ran a stateful Cloudflare Durable Object (`mcp.ts`) for this, but that has been removed in favor of always proxying to Hono. ### Protocol dialects: legacy stateful and 2026-07-28 stateless The Hono dispatcher serves both MCP dialects side by side (`src/lib/stateless-protocol.ts` holds the shared constants): - **Legacy (≤2025-11-25)**: the `initialize` handshake negotiates a protocol version, the server mints an `Mcp-Session-Id`, and clients echo it on subsequent requests. Unchanged wire shape. - **Stateless (2026-07-28, SEP-2575)**: no handshake and no protocol-level sessions. Each request self-describes via reserved `_meta` keys (`io.modelcontextprotocol/protocolVersion`, `.../clientInfo`, `.../clientCapabilities` — the first is the dialect switch, all three are mandatory and missing ones are rejected with `-32602` + HTTP 400); capability discovery happens through the mandatory `server/discover` RPC (capabilities + `serverInfo` + instructions + `supportedVersions`). Results carry `resultType: "complete"`, the server's identity in `_meta` (`io.modelcontextprotocol/serverInfo`), and — for `CacheableResult` methods (`server/discover`, `tools/list`, `resources/list`, `resources/read`, `prompts/list`) — `ttlMs`/`cacheScope: "private"` freshness hints. The TTL is nonzero in production only — locally `ttlMs: 0` (the spec-compliant "don't cache") keeps SDK client response caches from serving stale results while iterating on tool definitions. A request's dialect is detected per request from the `_meta` protocol-version key or a modern `MCP-Protocol-Version` header. Only modern versions (2026-07-28+) are valid there — legacy versions are implemented solely behind the `initialize` handshake, so `server/discover` advertises modern versions only, and a legacy or unknown `_meta` version is rejected with `UnsupportedProtocolVersionError` (`-32022` on HTTP 400, with the spec's machine-readable `data.supported`/`data.requested` payload). Modern requests must also carry SEP-2243's operation headers — `MCP-Protocol-Version` (mirroring `_meta`), `Mcp-Method` (mirroring the body `method`), and `Mcp-Name` (mirroring `params.name`/`params.uri` on `tools/call`, `prompts/get`, `resources/read`) — so intermediaries can route without parsing bodies; a missing or contradicting header is rejected with `HeaderMismatch` (`-32020`) + HTTP 400 before dispatch. Modern messages are also barred from JSON-RPC arrays (`-32600` + 400; batching was removed from the protocol in 2025-06-18), and RPCs the modern dialect removed (`initialize`, `ping`) answer method-not-found on HTTP 404. Legacy clients are untouched by all of this: header-free requests — including ones sending a legacy `MCP-Protocol-Version` value such as `2025-06-18` — keep the exact pre-existing wire behavior (HTTP 200 with errors in the JSON-RPC body). Client identity for analytics is read from the `initialize` body for legacy clients and from per-request `_meta` for stateless clients. Stateless requests never mint or echo `Mcp-Session-Id` — cross-request correlation for that traffic relies on `mcpConversationId` (see below). ## File Structure ```txt src/ ├── index.ts # Worker entry point: OAuth, routing, /mcp proxy ├── proxy.ts # Region resolution + reverse proxy to the Hono runtime ├── hono/ # The Hono runtime that actually serves the MCP protocol └── lib/ # Shared helpers (caching, analytics, logging, …) ``` ## Key Concepts ### Worker → Hono communication The Worker resolves the caller's region (from cache/KV or by probing both regions) and reverse-proxies the request to the matching Hono deployment: ```typescript // In Worker (index.ts) if (url.pathname.startsWith('/mcp')) { const region = await resolveProxyRegion(token, ctx.props.userHash, env.MCP_KV) return proxyToHono(request, region) } ``` `RequestProperties` (the parsed headers and query params for a request) is defined in `src/lib/request-properties.ts` and shared by both runtimes. ### Per-User State The Hono runtime keeps per-user session state (active project/organization, region, distinctId) in Redis, namespaced by `userHash` — a PBKDF2 hash of the API token (see `src/lib/utils`), ensuring: - **Isolation**: Users can't access each other's cached data (different prefix) - **Persistence**: Region and distinctId survive across requests - **Deterministic**: Same token always produces the same hash/prefix - **Secure**: Tokens can't be reversed from the hash ### Wide Logging Pattern Instead of scattered log statements, we accumulate data into a single log object and emit once at the end: ```typescript const log = new RequestLogger() log.extend({ route: url.pathname }) log.extend({ region: effectiveRegion }) // ... handle request ... log.emit(response.status) // Single log with all data + duration ``` This produces one structured JSON log per request, making it easier to query in observability tools. ### Tracking and observability There are three independent layers that emit signals about each MCP request: 1. **PostHog analytics events** — `$mcp_tool_call` and friends, captured for product analytics. 2. **Outbound API headers** — propagated when the MCP server calls PostHog's Django backend, so backend log lines and OTLP spans can correlate with the originating MCP request. 3. **Wide structured logs** — single JSON record per request from the Worker itself (see [Wide Logging Pattern](#wide-logging-pattern) above). #### `$mcp_tool_call` event paths The canonical event is `$mcp_tool_call`. The legacy unprefixed `mcp_tool_call` alias is no longer emitted — the transition shim that dual-emitted it through the cutover has been removed (only pre-2026-06-16 history remains under that name). The path that fires depends on the server mode and on the `mcp-posthog-analytics-sdk` feature flag: - **`hono/analytics.ts`** — homegrown PostHog capture. Used by the exec-mode wrapper to emit events for inner tool calls. Properties use the bare form: `mcp_session_id`, `mcp_conversation_id`, `mcp_client_name`, etc. - **`lib/mcpcat.ts`** — legacy MCPcat SDK path. Same bare property names. - **`lib/posthog-mcp-analytics.ts`** — the [`@posthog/mcp-analytics`](https://github.com/PostHog/mcp-analytics) SDK. Property names are `$`-prefixed (`$mcp_session_id`, `$mcp_conversation_id`, …). This is the path most live traffic flows through today. Adding a new property to events means wiring it into the `McpCatIdentityProvider` interface and the property-builder in **all three** emitters, then sourcing the value on `requestProperties` (or pulling it from another DO-level source). #### Three correlation identifiers Three identifiers travel with each request, each with a different lifecycle and a different consumer: | Identifier | Source | Where it lands | | --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | **`sessionId`** (wrapper-app hint) | `?sessionId=` query param, set by integrators (setup wizard, sandbox, etc.) | Resolved to a UUIDv7 via `SessionManager.getSessionUuid()` and stamped as `$session_id` on events — drives Session Replay grouping. `$ai_session_id` is never stamped, so session-target AI evaluations cannot fire on MCP traffic. Only set when a wrapper supplies it. | | **`mcpSessionId`** (transport session) | `Mcp-Session-Id` HTTP header, server-minted on initialize per the [Streamable-HTTP MCP spec](https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#streamable-http) and echoed by clients on every subsequent request | Stamped on `mcp_tool_call` events as `mcp_session_id` / `$mcp_session_id`, and forwarded to Django as `X-Posthog-Mcp-Session-Id` so backend structlog contextvars + OTLP span attributes (`mcp.session_id`) can correlate. | | **`mcpConversationId`** (agent-echoed conversation) | The `conversation_id` arg [injected into tool schemas](https://github.com/PostHog/mcp-analytics/pull/14) by `@posthog/mcp-analytics` when `enableConversationId: true`. The SDK mints a UUID and asks the agent to echo it on subsequent calls, so it persists across transport reconnects | Same plumbing as `mcpSessionId` — stamped on events and forwarded to Django as `X-Posthog-Mcp-Conversation-Id`. | Crucially, **`sessionId` and `mcpSessionId` are different concepts** and will not match for the same request. The wrapper-app `sessionId` is only set for a small fraction of traffic (mostly integrator-driven flows); the transport `mcpSessionId` is on essentially every authenticated request after initialize. #### Forwarding session and conversation IDs to Django When the Worker calls PostHog's Django backend (any `ApiClient.fetch`), the outbound request carries: - `X-Posthog-Mcp-Session-Id: ` — when set on `ApiClient.config` - `X-Posthog-Mcp-Conversation-Id: ` — when set on `ApiClient.config` On the Django side, `per_request_logging_context_middleware` reads both headers (sanitized through the existing `sanitize_header_value` helper), binds them to structlog contextvars (`mcp_session_id`, `mcp_conversation_id`), and sets them on the current OTLP span as `mcp.session_id` / `mcp.conversation_id`. The headers are caller-asserted — anyone can spoof them on a request — so backend consumers should treat them as correlation hints, not authoritative identifiers. This is **attribute-based correlation, not distributed-trace span linkage** — the Worker emits no OTLP itself and forwards no `traceparent`, so the Django-rooted span is not a child of any Worker-side span. Tracing backends can correlate after the fact by querying on the attribute, but won't render a cross-service trace tree until Worker-side OTLP export ships. ## OAuth Flow The server implements RFC 9728 (OAuth Protected Resource Metadata) and RFC 8414 (OAuth Authorization Server Metadata): ```mermaid sequenceDiagram participant Client participant MCP as MCP Server participant PostHog as PostHog OAuth Client->>MCP: Connect without token MCP-->>Client: 401 + WWW-Authenticate header Client->>MCP: GET /.well-known/oauth-protected-resource/{path} MCP-->>Client: Authorization server URL (US or EU) Client->>PostHog: OAuth flow PostHog-->>Client: Access token Client->>MCP: Reconnect with token MCP-->>Client: MCP protocol ready ``` ## Wrangler Configuration The Worker no longer binds a Durable Object — `/mcp` is proxied to the Hono runtime. The historical DO migrations remain in `wrangler.jsonc` as tombstones, with a final `deleted_classes` migration that retires the class (and its per-user SQLite storage) in Cloudflare: ```jsonc { "migrations": [ { "new_sqlite_classes": ["MyMCP"], "tag": "v1" }, { "renamed_classes": [{ "from": "MyMCP", "to": "MCP" }], "tag": "v2" }, { "deleted_classes": ["MCP"], "tag": "v3" }, ], } ```