diff --git a/.env.example b/.env.example index acf98af..1e55b48 100644 --- a/.env.example +++ b/.env.example @@ -26,6 +26,10 @@ MINTLIFY_DOMAIN= REDIS_URL= # redis://127.0.0.1:6379 # REDIS_TLS_SERVER_NAME= # optional; requires REDIS_URL to use rediss:// +# PostHog MCP analytics. Unset disables capture entirely. +POSTHOG_PROJECT_TOKEN=phc_ +POSTHOG_HOST=https://us.i.posthog.com + # OAuth Client IDs KERNEL_CLI_PROD_CLIENT_ID= KERNEL_CLI_STAGING_CLIENT_ID= diff --git a/bun.lock b/bun.lock index cb24d63..98bc3f1 100644 --- a/bun.lock +++ b/bun.lock @@ -11,6 +11,7 @@ "@mcp-ui/server": "^5.10.0", "@modelcontextprotocol/sdk": "1.26.0", "@onkernel/sdk": "^0.78.0", + "@posthog/mcp": "0.10.1", "@types/jsonwebtoken": "^9.0.10", "@types/redis": "^4.0.11", "builtin-modules": "^5.0.0", @@ -23,6 +24,7 @@ "next": "^16.2.6", "next-themes": "^0.4.4", "playwright": "^1.49.1", + "posthog-node": "^5.46.1", "prettier": "^3.6.2", "react": "^19.2.1", "react-dom": "^19.2.1", @@ -147,6 +149,12 @@ "@onkernel/sdk": ["@onkernel/sdk@0.78.0", "", {}, "sha512-VrGEDcuSwO6AKe6oYTNaQsAHnOAVeqehmStDTM0EFd3u8+WhITYxhU+jNFq+9yEt0N/nrn2COsk/ThQ+dxWBlw=="], + "@posthog/core": ["@posthog/core@1.45.2", "", { "dependencies": { "@posthog/types": "^1.398.0" } }, "sha512-OhEHkojFkqEFbtm/wUtLYgomN1gFNU9IyufvNsuZvpIOh8TZ9tnAvI81Sej/2zu+vyDExs9JroQB5SW3y5QyOw=="], + + "@posthog/mcp": ["@posthog/mcp@0.10.1", "", { "dependencies": { "@posthog/core": "^1.45.1" }, "peerDependencies": { "@modelcontextprotocol/sdk": ">=1.26.0", "posthog-node": "^5.0.0" } }, "sha512-TMe6BvDCzMMaWP30jMuJKWDbqrDZ6xLjknzQFGXkaD5EPiq4EcU8FImjZBSZ6l1ipQdnPmkx2Z+R4bKhlx9Z6A=="], + + "@posthog/types": ["@posthog/types@1.398.0", "", {}, "sha512-sJMkl4k+u8yS/0fjHsKqE9xTdsAh30a2WvgChiptellnVoE0e8QJKFgqOMD2sk8FaEArPdeFklAhXvmENAt3Sg=="], + "@redis/bloom": ["@redis/bloom@5.6.0", "", { "peerDependencies": { "@redis/client": "^5.6.0" } }, "sha512-l13/d6BaZDJzogzZJEphIeZ8J0hpQpjkMiozomTm6nJiMNYkoPsNOBOOQua4QsG0fFjyPmLMDJFPAp5FBQtTXg=="], "@redis/client": ["@redis/client@5.6.0", "", { "dependencies": { "cluster-key-slot": "1.1.2" } }, "sha512-wmP9kCFElCSr4MM4+1E4VckDuN4wLtiXSM/J0rKVQppajxQhowci89RGZr2OdLualowb8SRJ/R6OjsXrn9ZNFA=="], @@ -461,6 +469,8 @@ "postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="], + "posthog-node": ["posthog-node@5.46.1", "", { "dependencies": { "@posthog/core": "^1.45.1" }, "peerDependencies": { "rxjs": "^7.0.0" }, "optionalPeers": ["rxjs"] }, "sha512-WjCqExq44pBdyg9MSsH6UAE0tNZ88p4aIuVFicgqhjf2Fbws6IhS4ioYUa4aBrbUPS9EDRXtBTtF5DpP1ml8Pw=="], + "prettier": ["prettier@3.6.2", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ=="], "process-nextick-args": ["process-nextick-args@2.0.1", "", {}, "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="], diff --git a/package.json b/package.json index 46f6178..57f008c 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "@mcp-ui/server": "^5.10.0", "@modelcontextprotocol/sdk": "1.26.0", "@onkernel/sdk": "^0.78.0", + "@posthog/mcp": "0.10.1", "@types/jsonwebtoken": "^9.0.10", "@types/redis": "^4.0.11", "builtin-modules": "^5.0.0", @@ -47,6 +48,7 @@ "next": "^16.2.6", "next-themes": "^0.4.4", "playwright": "^1.49.1", + "posthog-node": "^5.46.1", "prettier": "^3.6.2", "react": "^19.2.1", "react-dom": "^19.2.1", diff --git a/src/app/[transport]/route.ts b/src/app/[transport]/route.ts index 77648bb..78f133c 100644 --- a/src/app/[transport]/route.ts +++ b/src/app/[transport]/route.ts @@ -1,10 +1,16 @@ +import { MCP_SESSION_HEADER } from "@posthog/mcp"; import { createMcpHandler, experimental_withMcpAuth as withMcpAuth, } from "mcp-handler"; import { verifyToken } from "@clerk/nextjs/server"; -import { NextRequest } from "next/server"; +import { after, NextRequest } from "next/server"; import { isValidJwtFormat } from "@/lib/auth-utils"; +import { + flushMcpAnalytics, + instrumentMcpAnalytics, + mintMcpSessionId, +} from "@/lib/mcp/analytics"; import { registerMcpCapabilities } from "@/lib/mcp/register"; export async function OPTIONS(_req: NextRequest): Promise { @@ -13,7 +19,8 @@ export async function OPTIONS(_req: NextRequest): Promise { headers: { "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Methods": "GET, POST, OPTIONS", - "Access-Control-Allow-Headers": "Content-Type, Authorization", + "Access-Control-Allow-Headers": `Content-Type, Authorization, ${MCP_SESSION_HEADER}`, + "Access-Control-Expose-Headers": MCP_SESSION_HEADER, }, }); } @@ -43,6 +50,7 @@ function createAuthErrorResponse( // Create MCP handler with tools const handler = createMcpHandler((server) => { + instrumentMcpAnalytics(server); registerMcpCapabilities(server); }); @@ -118,9 +126,37 @@ async function handleAuthenticatedRequest(req: NextRequest): Promise { } export async function GET(req: NextRequest): Promise { + after(flushMcpAnalytics); return await handleAuthenticatedRequest(req); } export async function POST(req: NextRequest): Promise { - return await handleAuthenticatedRequest(req); + after(flushMcpAnalytics); + + const sessionId = await mintMcpSessionId(req); + if (!sessionId) return await handleAuthenticatedRequest(req); + + // Pass the token in on the handshake too, so the initialize event lands in the same + // session as the calls that follow it. + const requestHeaders = new Headers(req.headers); + requestHeaders.set(MCP_SESSION_HEADER, sessionId); + const response = await handleAuthenticatedRequest( + new NextRequest(req.url, { + method: req.method, + headers: requestHeaders, + body: await req.text(), + signal: req.signal, + }), + ); + + const headers = new Headers(response.headers); + headers.set(MCP_SESSION_HEADER, sessionId); + // Preflight allows the header; a browser only gets to read it if the response that + // carries it says so too. + headers.set("Access-Control-Expose-Headers", MCP_SESSION_HEADER); + return new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers, + }); } diff --git a/src/lib/mcp/analytics.ts b/src/lib/mcp/analytics.ts new file mode 100644 index 0000000..e19ec97 --- /dev/null +++ b/src/lib/mcp/analytics.ts @@ -0,0 +1,147 @@ +import { + encodeSessionId, + instrument, + MCP_SESSION_HEADER, + newSessionId, + PostHogMCPAnalyticsEvent, + PostHogMCPAnalyticsProperty, +} from "@posthog/mcp"; +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { PostHog } from "posthog-node"; + +const projectToken = process.env.POSTHOG_PROJECT_TOKEN; + +if (!projectToken && process.env.NODE_ENV !== "production") { + console.error( + "POSTHOG_PROJECT_TOKEN variable required by PostHog is missing or un-configured, this causes events to be silently missed. This error stops appearing once POSTHOG_PROJECT_TOKEN is configured", + ); +} + +// Created once per lambda instance, never per request. +const posthog = projectToken + ? new PostHog(projectToken, { + host: process.env.POSTHOG_HOST ?? "https://us.i.posthog.com", + flushAt: 1, + flushInterval: 0, + }) + : null; + +// Every property this integration sends. An allow-list rather than a deny-list so a +// property the pinned SDK doesn't emit today — a renamed payload field, a new one — +// can't start flowing on an upgrade. Deliberately absent: $mcp_parameters and +// $mcp_response (call payloads), $mcp_error_message (the text a failed tool returned), +// and $mcp_intent / $mcp_intent_source (agent-written, and intent capture is off). +const SENT_PROPERTIES = new Set([ + "$groups", + "$process_person_profile", + PostHogMCPAnalyticsProperty.ClientName, + PostHogMCPAnalyticsProperty.ClientVersion, + PostHogMCPAnalyticsProperty.DurationMs, + PostHogMCPAnalyticsProperty.ErrorType, + PostHogMCPAnalyticsProperty.IsError, + PostHogMCPAnalyticsProperty.ListedToolNames, + PostHogMCPAnalyticsProperty.ProtocolVersion, + PostHogMCPAnalyticsProperty.ResourceName, + PostHogMCPAnalyticsProperty.ServerName, + PostHogMCPAnalyticsProperty.ServerVersion, + PostHogMCPAnalyticsProperty.SessionId, + PostHogMCPAnalyticsProperty.Source, + PostHogMCPAnalyticsProperty.ToolCategory, + PostHogMCPAnalyticsProperty.ToolDescription, + PostHogMCPAnalyticsProperty.ToolName, +]); + +/** + * Captures every tool call, tools/list, and initialize handled by the server as a + * `$mcp_*` PostHog event. No-op when POSTHOG_PROJECT_TOKEN is unset. + */ +export function instrumentMcpAnalytics(server: McpServer) { + if (!posthog) return; + + instrument(server, posthog, { + // Intent capture injects a required `context` argument into every tool schema. + // Left off so the public tool surface is unchanged. + context: false, + // A failed tool call otherwise fans out into a second `$exception` event whose + // `$exception_list` is built from the text the tool returned. + enableExceptionAutocapture: false, + // Events are attributed to the analytics session, with no person created. The only + // id this server holds is the Clerk subject, while every other producer in these + // projects identifies people by their Kernel user id — identifying on the subject + // would mint a second profile per person. Resolving the Kernel user id needs an + // API that doesn't exist yet, so user-level reporting is out of scope until then. + identify: null, + // No part of a call is safe to capture: arguments carry free-form input (credential + // field maps, curl headers and bodies, typed text, shell commands, Playwright + // source), results are serialized to a JSON string (see jsonResponse) so the SDK's + // key-name redaction can't see inside them, and a tool's error text is whatever + // upstream returned. Send call metadata only. + beforeSend: (event) => { + if (event.event === PostHogMCPAnalyticsEvent.Exception) return null; + + const properties = event.properties; + if (!properties) return event; + + for (const key of Object.keys(properties)) { + if (!SENT_PROPERTIES.has(key)) delete properties[key]; + } + + return event; + }, + }); +} + +type InitializeRequestBody = { + method?: string; + params?: { + clientInfo?: { name?: string; version?: string }; + protocolVersion?: string; + }; +}; + +/** + * mcp-handler answers over SSE with a stateless transport, so it never issues an + * `Mcp-Session-Id`. Left alone every request becomes its own PostHog session and the + * client name is lost after the handshake. Mint the SDK's session token on the + * initialize request instead: it goes back to the client on the response, the client + * replays it, and any instance decodes the same session id and client info out of it. + * + * Returns the token, or null when there's nothing to mint. Safe on the stateless + * transport, which ignores an incoming session id. + */ +export async function mintMcpSessionId(req: Request): Promise { + if (!posthog || req.headers.get(MCP_SESSION_HEADER)) return null; + + // Streamable HTTP only. The legacy SSE transport issues its own session id and its + // clients don't replay ours, which would put the handshake in one session and the + // calls that follow in another. + if (!new URL(req.url).pathname.endsWith("/mcp")) return null; + + const body = (await req + .clone() + .json() + .catch(() => null)) as InitializeRequestBody | null; + if (body?.method !== "initialize") return null; + + return encodeSessionId({ + sessionId: newSessionId(), + clientName: body.params?.clientInfo?.name, + clientVersion: body.params?.clientInfo?.version, + // Negotiated once, at the handshake. Carried in the token so the events after it + // report it too. + protocolVersion: body.params?.protocolVersion, + }); +} + +/** + * Drains queued events after the response has been sent, so capture never adds + * latency to a tool call. + */ +export async function flushMcpAnalytics() { + if (!posthog) return; + try { + await posthog.flush(); + } catch (error) { + console.error("Failed to flush PostHog MCP analytics events", error); + } +}