Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ MINTLIFY_DOMAIN=<x>
REDIS_URL=<x> # redis://127.0.0.1:6379
# REDIS_TLS_SERVER_NAME=<x> # optional; requires REDIS_URL to use rediss://

# PostHog MCP analytics. Unset disables capture entirely.
POSTHOG_PROJECT_TOKEN=phc_<x>
POSTHOG_HOST=https://us.i.posthog.com

# OAuth Client IDs
KERNEL_CLI_PROD_CLIENT_ID=<x>
KERNEL_CLI_STAGING_CLIENT_ID=<x>
Expand Down
10 changes: 10 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
42 changes: 39 additions & 3 deletions src/app/[transport]/route.ts
Original file line number Diff line number Diff line change
@@ -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<Response> {
Expand All @@ -13,7 +19,8 @@ export async function OPTIONS(_req: NextRequest): Promise<Response> {
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,
},
});
}
Expand Down Expand Up @@ -43,6 +50,7 @@ function createAuthErrorResponse(

// Create MCP handler with tools
const handler = createMcpHandler((server) => {
instrumentMcpAnalytics(server);
registerMcpCapabilities(server);
});

Expand Down Expand Up @@ -118,9 +126,37 @@ async function handleAuthenticatedRequest(req: NextRequest): Promise<Response> {
}

export async function GET(req: NextRequest): Promise<Response> {
after(flushMcpAnalytics);
return await handleAuthenticatedRequest(req);
}

export async function POST(req: NextRequest): Promise<Response> {
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,
});
Comment thread
cursor[bot] marked this conversation as resolved.
}
147 changes: 147 additions & 0 deletions src/lib/mcp/analytics.ts
Original file line number Diff line number Diff line change
@@ -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<string>([
"$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;
},
});
Comment thread
cursor[bot] marked this conversation as resolved.
}

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<string | null> {
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);
}
}
Loading