From 5fb32714bbe96fbbff2375246c494060fa2fea45 Mon Sep 17 00:00:00 2001 From: masnwilliams <43387599+masnwilliams@users.noreply.github.com> Date: Wed, 29 Jul 2026 19:07:05 +0000 Subject: [PATCH 1/9] Capture MCP tool calls in PostHog --- .env.example | 4 +++ bun.lock | 10 +++++++ package.json | 2 ++ src/app/[transport]/route.ts | 10 +++++-- src/lib/mcp/analytics.ts | 58 ++++++++++++++++++++++++++++++++++++ 5 files changed, 82 insertions(+), 2 deletions(-) create mode 100644 src/lib/mcp/analytics.ts 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..00e79af 100644 --- a/src/app/[transport]/route.ts +++ b/src/app/[transport]/route.ts @@ -5,6 +5,7 @@ import { import { verifyToken } from "@clerk/nextjs/server"; import { NextRequest } from "next/server"; import { isValidJwtFormat } from "@/lib/auth-utils"; +import { flushMcpAnalytics, instrumentMcpAnalytics } from "@/lib/mcp/analytics"; import { registerMcpCapabilities } from "@/lib/mcp/register"; export async function OPTIONS(_req: NextRequest): Promise { @@ -43,6 +44,7 @@ function createAuthErrorResponse( // Create MCP handler with tools const handler = createMcpHandler((server) => { + instrumentMcpAnalytics(server); registerMcpCapabilities(server); }); @@ -118,9 +120,13 @@ async function handleAuthenticatedRequest(req: NextRequest): Promise { } export async function GET(req: NextRequest): Promise { - return await handleAuthenticatedRequest(req); + const response = await handleAuthenticatedRequest(req); + await flushMcpAnalytics(); + return response; } export async function POST(req: NextRequest): Promise { - return await handleAuthenticatedRequest(req); + const response = await handleAuthenticatedRequest(req); + await flushMcpAnalytics(); + return response; } diff --git a/src/lib/mcp/analytics.ts b/src/lib/mcp/analytics.ts new file mode 100644 index 0000000..8b4d50c --- /dev/null +++ b/src/lib/mcp/analytics.ts @@ -0,0 +1,58 @@ +import { instrument } 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", + enableExceptionAutocapture: true, + flushAt: 1, + flushInterval: 0, + }) + : null; + +function clerkUserId(extra?: Record): string | null { + const authInfo = extra?.authInfo as + | { extra?: { userId?: string | null } } + | undefined; + return authInfo?.extra?.userId ?? null; +} + +/** + * 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, { + // The transport is stateless on Vercel, so a session id is not stable across + // requests. Attribute to the Clerk user when the request carries a JWT; API-key + // requests stay session-scoped. + identify: async (_request, extra) => { + const userId = clerkUserId(extra); + return userId ? { distinctId: userId } : null; + }, + }); +} + +/** + * Sends queued events before the serverless invocation is frozen. + */ +export async function flushMcpAnalytics() { + if (!posthog) return; + try { + await posthog.flush(); + } catch (error) { + console.error("Failed to flush PostHog MCP analytics events", error); + } +} From 981d5644c6f9f88f12391bbf0c25eeaa825b0435 Mon Sep 17 00:00:00 2001 From: masnwilliams <43387599+masnwilliams@users.noreply.github.com> Date: Wed, 29 Jul 2026 19:16:38 +0000 Subject: [PATCH 2/9] Flush analytics after the response and drop the injected context argument --- src/app/[transport]/route.ts | 12 +++++------- src/lib/mcp/analytics.ts | 6 +++++- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/src/app/[transport]/route.ts b/src/app/[transport]/route.ts index 00e79af..24dcc53 100644 --- a/src/app/[transport]/route.ts +++ b/src/app/[transport]/route.ts @@ -3,7 +3,7 @@ import { 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 } from "@/lib/mcp/analytics"; import { registerMcpCapabilities } from "@/lib/mcp/register"; @@ -120,13 +120,11 @@ async function handleAuthenticatedRequest(req: NextRequest): Promise { } export async function GET(req: NextRequest): Promise { - const response = await handleAuthenticatedRequest(req); - await flushMcpAnalytics(); - return response; + after(flushMcpAnalytics); + return await handleAuthenticatedRequest(req); } export async function POST(req: NextRequest): Promise { - const response = await handleAuthenticatedRequest(req); - await flushMcpAnalytics(); - return response; + after(flushMcpAnalytics); + return await handleAuthenticatedRequest(req); } diff --git a/src/lib/mcp/analytics.ts b/src/lib/mcp/analytics.ts index 8b4d50c..702d6e5 100644 --- a/src/lib/mcp/analytics.ts +++ b/src/lib/mcp/analytics.ts @@ -35,6 +35,9 @@ 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, // The transport is stateless on Vercel, so a session id is not stable across // requests. Attribute to the Clerk user when the request carries a JWT; API-key // requests stay session-scoped. @@ -46,7 +49,8 @@ export function instrumentMcpAnalytics(server: McpServer) { } /** - * Sends queued events before the serverless invocation is frozen. + * 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; From b1a9ccfd776b4d4db3e17fff8d943a2f77d6a916 Mon Sep 17 00:00:00 2001 From: masnwilliams <43387599+masnwilliams@users.noreply.github.com> Date: Wed, 29 Jul 2026 19:35:01 +0000 Subject: [PATCH 3/9] Drop response payloads and credential arguments before send --- src/lib/mcp/analytics.ts | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/src/lib/mcp/analytics.ts b/src/lib/mcp/analytics.ts index 702d6e5..8dda6dd 100644 --- a/src/lib/mcp/analytics.ts +++ b/src/lib/mcp/analytics.ts @@ -1,4 +1,4 @@ -import { instrument } from "@posthog/mcp"; +import { instrument, PostHogMCPAnalyticsProperty } from "@posthog/mcp"; import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { PostHog } from "posthog-node"; @@ -20,6 +20,14 @@ const posthog = projectToken }) : null; +// Tools that take or return credentials. Their arguments carry arbitrary field/value +// maps (e.g. manage_credentials `values`) that key-name redaction can't cover. +const CREDENTIAL_TOOLS = new Set([ + "manage_api_keys", + "manage_credentials", + "manage_proxies", +]); + function clerkUserId(extra?: Record): string | null { const authInfo = extra?.authInfo as | { extra?: { userId?: string | null } } @@ -45,6 +53,22 @@ export function instrumentMcpAnalytics(server: McpServer) { const userId = clerkUserId(extra); return userId ? { distinctId: userId } : null; }, + beforeSend: (event) => { + const properties = event.properties; + if (!properties) return event; + + // Every tool serializes its result to a JSON string (see jsonResponse), so the + // SDK's key-name redaction can't see inside it — a created API key or a TOTP + // code would go out verbatim. Keep the call metadata, drop the payload. + delete properties[PostHogMCPAnalyticsProperty.Response]; + + const toolName = properties[PostHogMCPAnalyticsProperty.ToolName]; + if (typeof toolName === "string" && CREDENTIAL_TOOLS.has(toolName)) { + delete properties[PostHogMCPAnalyticsProperty.Parameters]; + } + + return event; + }, }); } From cf6d63495349cfd54dbf8fd5db4e2f6a4ba505ae Mon Sep 17 00:00:00 2001 From: masnwilliams <43387599+masnwilliams@users.noreply.github.com> Date: Wed, 29 Jul 2026 21:54:36 +0000 Subject: [PATCH 4/9] Drop auth connection submit parameters before send --- src/lib/mcp/analytics.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/lib/mcp/analytics.ts b/src/lib/mcp/analytics.ts index 8dda6dd..057a565 100644 --- a/src/lib/mcp/analytics.ts +++ b/src/lib/mcp/analytics.ts @@ -21,9 +21,11 @@ const posthog = projectToken : null; // Tools that take or return credentials. Their arguments carry arbitrary field/value -// maps (e.g. manage_credentials `values`) that key-name redaction can't cover. +// maps (`manage_credentials` values, `manage_auth_connections` fields) that key-name +// redaction can't cover. const CREDENTIAL_TOOLS = new Set([ "manage_api_keys", + "manage_auth_connections", "manage_credentials", "manage_proxies", ]); From 47a73cb4872c52c68c16a66c6099a3e28b8481b7 Mon Sep 17 00:00:00 2001 From: masnwilliams <43387599+masnwilliams@users.noreply.github.com> Date: Wed, 29 Jul 2026 22:11:40 +0000 Subject: [PATCH 5/9] Mint a session id so stateless requests share one MCP session --- src/app/[transport]/route.ts | 34 ++++++++++++++++++++++++++++--- src/lib/mcp/analytics.ts | 39 +++++++++++++++++++++++++++++++++++- 2 files changed, 69 insertions(+), 4 deletions(-) diff --git a/src/app/[transport]/route.ts b/src/app/[transport]/route.ts index 24dcc53..c9ae8f8 100644 --- a/src/app/[transport]/route.ts +++ b/src/app/[transport]/route.ts @@ -1,3 +1,4 @@ +import { MCP_SESSION_HEADER } from "@posthog/mcp"; import { createMcpHandler, experimental_withMcpAuth as withMcpAuth, @@ -5,7 +6,11 @@ import { import { verifyToken } from "@clerk/nextjs/server"; import { after, NextRequest } from "next/server"; import { isValidJwtFormat } from "@/lib/auth-utils"; -import { flushMcpAnalytics, instrumentMcpAnalytics } from "@/lib/mcp/analytics"; +import { + flushMcpAnalytics, + instrumentMcpAnalytics, + mintMcpSessionId, +} from "@/lib/mcp/analytics"; import { registerMcpCapabilities } from "@/lib/mcp/register"; export async function OPTIONS(_req: NextRequest): Promise { @@ -14,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, }, }); } @@ -126,5 +132,27 @@ export async function GET(req: NextRequest): Promise { export async function POST(req: NextRequest): Promise { after(flushMcpAnalytics); - return await handleAuthenticatedRequest(req); + + 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(), + }), + ); + + const headers = new Headers(response.headers); + headers.set(MCP_SESSION_HEADER, sessionId); + 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 index 057a565..33d2cce 100644 --- a/src/lib/mcp/analytics.ts +++ b/src/lib/mcp/analytics.ts @@ -1,4 +1,10 @@ -import { instrument, PostHogMCPAnalyticsProperty } from "@posthog/mcp"; +import { + encodeSessionId, + instrument, + MCP_SESSION_HEADER, + newSessionId, + PostHogMCPAnalyticsProperty, +} from "@posthog/mcp"; import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { PostHog } from "posthog-node"; @@ -74,6 +80,37 @@ export function instrumentMcpAnalytics(server: McpServer) { }); } +type InitializeRequestBody = { + method?: string; + params?: { clientInfo?: { name?: string; version?: 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; + + 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, + }); +} + /** * Drains queued events after the response has been sent, so capture never adds * latency to a tool call. From 01a6476cba2d1adadc001019892b5d4a1bc7df9f Mon Sep 17 00:00:00 2001 From: masnwilliams <43387599+masnwilliams@users.noreply.github.com> Date: Wed, 29 Jul 2026 22:16:51 +0000 Subject: [PATCH 6/9] Stop capturing tool arguments alongside responses --- next-env.d.ts | 2 +- src/lib/mcp/analytics.ts | 24 ++++++------------------ 2 files changed, 7 insertions(+), 19 deletions(-) diff --git a/next-env.d.ts b/next-env.d.ts index 9edff1c..c4b7818 100644 --- a/next-env.d.ts +++ b/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/types/routes.d.ts"; +import "./.next/dev/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/src/lib/mcp/analytics.ts b/src/lib/mcp/analytics.ts index 33d2cce..9dc900a 100644 --- a/src/lib/mcp/analytics.ts +++ b/src/lib/mcp/analytics.ts @@ -26,16 +26,6 @@ const posthog = projectToken }) : null; -// Tools that take or return credentials. Their arguments carry arbitrary field/value -// maps (`manage_credentials` values, `manage_auth_connections` fields) that key-name -// redaction can't cover. -const CREDENTIAL_TOOLS = new Set([ - "manage_api_keys", - "manage_auth_connections", - "manage_credentials", - "manage_proxies", -]); - function clerkUserId(extra?: Record): string | null { const authInfo = extra?.authInfo as | { extra?: { userId?: string | null } } @@ -61,20 +51,18 @@ export function instrumentMcpAnalytics(server: McpServer) { const userId = clerkUserId(extra); return userId ? { distinctId: userId } : null; }, + // Neither side of a call is safe to capture. Results are serialized to a JSON + // string (see jsonResponse) so the SDK's key-name redaction can't see inside them, + // and arguments carry free-form input — credential field maps, curl headers and + // bodies, typed text, shell commands, Playwright source. Keep the call metadata + // (tool, latency, error, client, session) and drop both payloads. beforeSend: (event) => { const properties = event.properties; if (!properties) return event; - // Every tool serializes its result to a JSON string (see jsonResponse), so the - // SDK's key-name redaction can't see inside it — a created API key or a TOTP - // code would go out verbatim. Keep the call metadata, drop the payload. + delete properties[PostHogMCPAnalyticsProperty.Parameters]; delete properties[PostHogMCPAnalyticsProperty.Response]; - const toolName = properties[PostHogMCPAnalyticsProperty.ToolName]; - if (typeof toolName === "string" && CREDENTIAL_TOOLS.has(toolName)) { - delete properties[PostHogMCPAnalyticsProperty.Parameters]; - } - return event; }, }); From d9b21ea82711ec43a0843a3671122b3bb6316ee9 Mon Sep 17 00:00:00 2001 From: masnwilliams <43387599+masnwilliams@users.noreply.github.com> Date: Wed, 29 Jul 2026 22:34:43 +0000 Subject: [PATCH 7/9] Expose the session header on the response that sets it --- src/app/[transport]/route.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/app/[transport]/route.ts b/src/app/[transport]/route.ts index c9ae8f8..9be6a63 100644 --- a/src/app/[transport]/route.ts +++ b/src/app/[transport]/route.ts @@ -150,6 +150,9 @@ export async function POST(req: NextRequest): Promise { 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, From 1de2e9a5377a88b374840374048cd1ec6129d895 Mon Sep 17 00:00:00 2001 From: masnwilliams <43387599+masnwilliams@users.noreply.github.com> Date: Wed, 29 Jul 2026 22:46:44 +0000 Subject: [PATCH 8/9] Restore generated next-env types path --- next-env.d.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/next-env.d.ts b/next-env.d.ts index c4b7818..9edff1c 100644 --- a/next-env.d.ts +++ b/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/dev/types/routes.d.ts"; +import "./.next/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. From 96adad9cc04cc8307c83c136280870196085d637 Mon Sep 17 00:00:00 2001 From: masnwilliams <43387599+masnwilliams@users.noreply.github.com> Date: Thu, 30 Jul 2026 16:26:35 +0000 Subject: [PATCH 9/9] Send MCP call metadata only, on an allow-list Removes process-level exception autocapture, drops the $exception sibling and free-form error text a failed tool call produced, and replaces the payload deny-list with an allow-list of the properties this integration intends to send. Also stops identifying on the Clerk subject, which is not the identity other producers in these PostHog projects use, carries the negotiated protocol version in the session token, limits session minting to the streamable HTTP transport, and forwards the abort signal when reconstructing the initialize request. --- src/app/[transport]/route.ts | 1 + src/lib/mcp/analytics.ts | 78 ++++++++++++++++++++++++++---------- 2 files changed, 57 insertions(+), 22 deletions(-) diff --git a/src/app/[transport]/route.ts b/src/app/[transport]/route.ts index 9be6a63..78f133c 100644 --- a/src/app/[transport]/route.ts +++ b/src/app/[transport]/route.ts @@ -145,6 +145,7 @@ export async function POST(req: NextRequest): Promise { method: req.method, headers: requestHeaders, body: await req.text(), + signal: req.signal, }), ); diff --git a/src/lib/mcp/analytics.ts b/src/lib/mcp/analytics.ts index 9dc900a..e19ec97 100644 --- a/src/lib/mcp/analytics.ts +++ b/src/lib/mcp/analytics.ts @@ -3,6 +3,7 @@ import { instrument, MCP_SESSION_HEADER, newSessionId, + PostHogMCPAnalyticsEvent, PostHogMCPAnalyticsProperty, } from "@posthog/mcp"; import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; @@ -20,18 +21,35 @@ if (!projectToken && process.env.NODE_ENV !== "production") { const posthog = projectToken ? new PostHog(projectToken, { host: process.env.POSTHOG_HOST ?? "https://us.i.posthog.com", - enableExceptionAutocapture: true, flushAt: 1, flushInterval: 0, }) : null; -function clerkUserId(extra?: Record): string | null { - const authInfo = extra?.authInfo as - | { extra?: { userId?: string | null } } - | undefined; - return authInfo?.extra?.userId ?? 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 @@ -44,24 +62,29 @@ export function instrumentMcpAnalytics(server: McpServer) { // Intent capture injects a required `context` argument into every tool schema. // Left off so the public tool surface is unchanged. context: false, - // The transport is stateless on Vercel, so a session id is not stable across - // requests. Attribute to the Clerk user when the request carries a JWT; API-key - // requests stay session-scoped. - identify: async (_request, extra) => { - const userId = clerkUserId(extra); - return userId ? { distinctId: userId } : null; - }, - // Neither side of a call is safe to capture. Results are serialized to a JSON - // string (see jsonResponse) so the SDK's key-name redaction can't see inside them, - // and arguments carry free-form input — credential field maps, curl headers and - // bodies, typed text, shell commands, Playwright source. Keep the call metadata - // (tool, latency, error, client, session) and drop both payloads. + // 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; - delete properties[PostHogMCPAnalyticsProperty.Parameters]; - delete properties[PostHogMCPAnalyticsProperty.Response]; + for (const key of Object.keys(properties)) { + if (!SENT_PROPERTIES.has(key)) delete properties[key]; + } return event; }, @@ -70,7 +93,10 @@ export function instrumentMcpAnalytics(server: McpServer) { type InitializeRequestBody = { method?: string; - params?: { clientInfo?: { name?: string; version?: string } }; + params?: { + clientInfo?: { name?: string; version?: string }; + protocolVersion?: string; + }; }; /** @@ -86,6 +112,11 @@ type InitializeRequestBody = { 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() @@ -96,6 +127,9 @@ export async function mintMcpSessionId(req: Request): Promise { 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, }); }