Skip to content
Open
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
12 changes: 12 additions & 0 deletions src/app/[transport]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,11 @@ import { after, NextRequest } from "next/server";
import { isValidJwtFormat } from "@/lib/auth-utils";
import {
captureMcpConnectionScopeFailure,
clientCapabilityAnalyticsFromInitialize,
flushMcpAnalytics,
instrumentMcpAnalytics,
isMcpAnalyticsEnabled,
type McpClientCapabilityAnalytics,
} from "@/lib/mcp/analytics";
import {
connectionAnalyticsFromContext,
Expand Down Expand Up @@ -130,6 +132,7 @@ async function handleMcpRequestWithIdentity({
transportSessionId,
connectionContextCacheIdentity,
observeConnection,
clientCapabilityAnalytics,
}: {
req: NextRequest;
token: string;
Expand All @@ -140,6 +143,7 @@ async function handleMcpRequestWithIdentity({
transportSessionId: string | null;
connectionContextCacheIdentity?: string;
observeConnection: boolean;
clientCapabilityAnalytics: McpClientCapabilityAnalytics | null;
}) {
const [mcpApps, connection] = await Promise.all([
requestUsesMcpApps(req, {
Expand Down Expand Up @@ -180,6 +184,7 @@ async function handleMcpRequestWithIdentity({
...authInfoExtra,
connectionContext,
connectionAnalytics,
clientCapabilityAnalytics,
},
}),
{
Expand All @@ -194,6 +199,7 @@ async function handleAuthenticatedRequest(
req: NextRequest,
transportSessionId: string | null = null,
observeConnection = false,
clientCapabilityAnalytics: McpClientCapabilityAnalytics | null = null,
): Promise<Response> {
const authHeader = req.headers.get("Authorization");
const token = authHeader?.startsWith("Bearer ")
Expand All @@ -219,6 +225,7 @@ async function handleAuthenticatedRequest(
credentialType: "api_key",
transportSessionId,
observeConnection,
clientCapabilityAnalytics,
});
}

Expand Down Expand Up @@ -256,6 +263,7 @@ async function handleAuthenticatedRequest(
? `${authSubject}\0${transportSessionId}`
: undefined,
observeConnection,
clientCapabilityAnalytics,
});
}

Expand Down Expand Up @@ -286,6 +294,9 @@ export async function POST(req: NextRequest): Promise<Response> {
}
const isInitialize = parsed?.method === "initialize";
const initializeParams = isInitialize ? parsed?.params : undefined;
const clientCapabilityAnalytics = isInitialize
? clientCapabilityAnalyticsFromInitialize(parsed)
: null;
const isStreamableInitialize =
new URL(req.url).pathname.endsWith("/mcp") && isInitialize;
const session = isStreamableInitialize
Expand All @@ -310,6 +321,7 @@ export async function POST(req: NextRequest): Promise<Response> {
}),
session?.id ?? null,
isInitialize,
clientCapabilityAnalytics,
);

if (!session) return response;
Expand Down
110 changes: 107 additions & 3 deletions src/lib/mcp/analytics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,16 @@ import {
import {
captureMcpConnectionScopeFailure,
captureOAuthTokenExchange,
clientCapabilityAnalyticsFromInitialize,
enrichMcpAnalyticsEvent,
instrumentMcpAnalytics,
MCP_CLIENT_ELICITATION_MODE_PROPERTY,
MCP_CLIENT_SUPPORTS_APPS_PROPERTY,
MCP_CLIENT_SUPPORTS_ENTERPRISE_AUTH_PROPERTY,
MCP_CLIENT_SUPPORTS_OAUTH_CLIENT_CREDENTIALS_PROPERTY,
MCP_CLIENT_SUPPORTS_SAMPLING_PROPERTY,
MCP_CLIENT_SUPPORTS_SAMPLING_TOOLS_PROPERTY,
MCP_CLIENT_SUPPORTS_TASKS_PROPERTY,
MCP_CONNECTION_SCOPE_FAILURE_EVENT,
MCP_USED_PROJECT_ID_PROPERTY,
MCP_USED_PROJECT_PROPERTY,
Expand All @@ -19,6 +27,85 @@ import {

const privateContextProperty = "__mcp_connection_analytics_context";

function initialize(capabilities: Record<string, unknown>) {
return {
jsonrpc: "2.0",
id: 1,
method: "initialize",
params: {
protocolVersion: "2025-11-25",
capabilities,
clientInfo: { name: "test-client", version: "1.0.0" },
},
};
}

describe("clientCapabilityAnalyticsFromInitialize", () => {
test("records unsupported capabilities as explicit false values", () => {
expect(clientCapabilityAnalyticsFromInitialize(initialize({}))).toEqual({
[MCP_CLIENT_SUPPORTS_SAMPLING_PROPERTY]: false,
[MCP_CLIENT_SUPPORTS_SAMPLING_TOOLS_PROPERTY]: false,
[MCP_CLIENT_ELICITATION_MODE_PROPERTY]: "none",
[MCP_CLIENT_SUPPORTS_APPS_PROPERTY]: false,
[MCP_CLIENT_SUPPORTS_TASKS_PROPERTY]: false,
[MCP_CLIENT_SUPPORTS_OAUTH_CLIENT_CREDENTIALS_PROPERTY]: false,
[MCP_CLIENT_SUPPORTS_ENTERPRISE_AUTH_PROPERTY]: false,
});
});

test("reduces standard capabilities and allowlisted extensions", () => {
const result = clientCapabilityAnalyticsFromInitialize(
initialize({
sampling: { tools: {} },
elicitation: {},
extensions: {
"io.modelcontextprotocol/ui": { mimeTypes: ["text/html"] },
"io.modelcontextprotocol/tasks": {},
"io.modelcontextprotocol/oauth-client-credentials": {},
"io.modelcontextprotocol/enterprise-managed-authorization": {},
"com.example/private-extension": { secret: "do-not-capture" },
},
}),
);

expect(result).toEqual({
[MCP_CLIENT_SUPPORTS_SAMPLING_PROPERTY]: true,
[MCP_CLIENT_SUPPORTS_SAMPLING_TOOLS_PROPERTY]: true,
[MCP_CLIENT_ELICITATION_MODE_PROPERTY]: "form",
[MCP_CLIENT_SUPPORTS_APPS_PROPERTY]: true,
[MCP_CLIENT_SUPPORTS_TASKS_PROPERTY]: true,
[MCP_CLIENT_SUPPORTS_OAUTH_CLIENT_CREDENTIALS_PROPERTY]: true,
[MCP_CLIENT_SUPPORTS_ENTERPRISE_AUTH_PROPERTY]: true,
});
expect(JSON.stringify(result)).not.toContain("private-extension");
expect(JSON.stringify(result)).not.toContain("do-not-capture");
});

test("distinguishes URL-only, form-and-URL, and legacy task support", () => {
const urlOnly = clientCapabilityAnalyticsFromInitialize(
initialize({ elicitation: { url: {} } }),
);
const formAndUrl = clientCapabilityAnalyticsFromInitialize(
initialize({ elicitation: { form: {}, url: {} }, tasks: {} }),
);

expect(urlOnly?.[MCP_CLIENT_ELICITATION_MODE_PROPERTY]).toBe("url");
expect(formAndUrl?.[MCP_CLIENT_ELICITATION_MODE_PROPERTY]).toBe(
"form_and_url",
);
expect(formAndUrl?.[MCP_CLIENT_SUPPORTS_TASKS_PROPERTY]).toBe(true);
});

test("ignores non-initialize payloads", () => {
expect(
clientCapabilityAnalyticsFromInitialize({
method: "tools/list",
params: { capabilities: { sampling: {} } },
}),
).toBeNull();
});
});

function initializeEvent(
sessionId: string,
context: {
Expand Down Expand Up @@ -459,12 +546,17 @@ describe("instrumentMcpAnalytics (SDK integration)", () => {
params: Record<string, unknown>,
) {
const server = makeServer(captured);
const request = { jsonrpc: "2.0", id: 1, method, params };
const extra = {
authInfo: {
token: "sk_test",
clientId: "mcp-server",
scopes: ["apikey"],
extra: { connectionContext: { scope: { organizationId: ORG } } },
extra: {
connectionContext: { scope: { organizationId: ORG } },
clientCapabilityAnalytics:
clientCapabilityAnalyticsFromInitialize(request),
},
},
signal: new AbortController().signal,
requestInfo: { headers: {} },
Expand All @@ -479,7 +571,7 @@ describe("instrumentMcpAnalytics (SDK integration)", () => {
)._requestHandlers;
const handler = handlers.get(method);
if (!handler) throw new Error(`no handler registered for ${method}`);
await handler({ jsonrpc: "2.0", id: 1, method, params }, extra);
await handler(request, extra);
// The SDK's event sink captures fire-and-forget; give it a tick to flush.
await new Promise((resolve) => setTimeout(resolve, 50));
}
Expand All @@ -489,7 +581,11 @@ describe("instrumentMcpAnalytics (SDK integration)", () => {

await simulateRequest(captured, "initialize", {
protocolVersion: "2025-03-26",
capabilities: {},
capabilities: {
sampling: { tools: {} },
elicitation: { url: {} },
extensions: { "io.modelcontextprotocol/ui": {} },
},
clientInfo: { name: "test-client", version: "0.0.0" },
});
await simulateRequest(captured, "tools/list", {});
Expand All @@ -513,6 +609,14 @@ describe("instrumentMcpAnalytics (SDK integration)", () => {
properties: Record<string, unknown>;
};

expect(initialize.properties).toMatchObject({
[MCP_CLIENT_SUPPORTS_SAMPLING_PROPERTY]: true,
[MCP_CLIENT_SUPPORTS_SAMPLING_TOOLS_PROPERTY]: true,
[MCP_CLIENT_ELICITATION_MODE_PROPERTY]: "url",
[MCP_CLIENT_SUPPORTS_APPS_PROPERTY]: true,
[MCP_CLIENT_SUPPORTS_TASKS_PROPERTY]: false,
});

// Every captured event carries org attribution...
for (const event of [initialize, toolsList, toolCall]) {
expect(event).toBeDefined();
Expand Down
117 changes: 117 additions & 0 deletions src/lib/mcp/analytics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,41 @@ const posthog = projectToken

export const MCP_USED_PROJECT_ID_PROPERTY = "$mcp_used_project_id";
export const MCP_USED_PROJECT_PROPERTY = "$mcp_used_project";
export const MCP_CLIENT_SUPPORTS_SAMPLING_PROPERTY =
"$mcp_client_supports_sampling";
export const MCP_CLIENT_SUPPORTS_SAMPLING_TOOLS_PROPERTY =
"$mcp_client_supports_sampling_tools";
export const MCP_CLIENT_ELICITATION_MODE_PROPERTY =
"$mcp_client_elicitation_mode";
export const MCP_CLIENT_SUPPORTS_APPS_PROPERTY = "$mcp_client_supports_apps";
export const MCP_CLIENT_SUPPORTS_TASKS_PROPERTY = "$mcp_client_supports_tasks";
export const MCP_CLIENT_SUPPORTS_OAUTH_CLIENT_CREDENTIALS_PROPERTY =
"$mcp_client_supports_oauth_client_credentials";
export const MCP_CLIENT_SUPPORTS_ENTERPRISE_AUTH_PROPERTY =
"$mcp_client_supports_enterprise_auth";

type McpClientElicitationMode = "none" | "form" | "url" | "form_and_url";

export type McpClientCapabilityAnalytics = {
[MCP_CLIENT_SUPPORTS_SAMPLING_PROPERTY]: boolean;
[MCP_CLIENT_SUPPORTS_SAMPLING_TOOLS_PROPERTY]: boolean;
[MCP_CLIENT_ELICITATION_MODE_PROPERTY]: McpClientElicitationMode;
[MCP_CLIENT_SUPPORTS_APPS_PROPERTY]: boolean;
[MCP_CLIENT_SUPPORTS_TASKS_PROPERTY]: boolean;
[MCP_CLIENT_SUPPORTS_OAUTH_CLIENT_CREDENTIALS_PROPERTY]: boolean;
[MCP_CLIENT_SUPPORTS_ENTERPRISE_AUTH_PROPERTY]: boolean;
};

// Official extensions listed at https://modelcontextprotocol.io/extensions.
// Keep this explicit: arbitrary extension identifiers and settings must not enter analytics.
const CLIENT_EXTENSION_PROPERTIES = {
"io.modelcontextprotocol/ui": MCP_CLIENT_SUPPORTS_APPS_PROPERTY,
"io.modelcontextprotocol/tasks": MCP_CLIENT_SUPPORTS_TASKS_PROPERTY,
"io.modelcontextprotocol/oauth-client-credentials":
MCP_CLIENT_SUPPORTS_OAUTH_CLIENT_CREDENTIALS_PROPERTY,
"io.modelcontextprotocol/enterprise-managed-authorization":
MCP_CLIENT_SUPPORTS_ENTERPRISE_AUTH_PROPERTY,
} as const;

// 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 —
Expand All @@ -84,6 +119,13 @@ const SENT_PROPERTIES = new Set<string>([
"$mcp_scope_source",
MCP_USED_PROJECT_ID_PROPERTY,
MCP_USED_PROJECT_PROPERTY,
MCP_CLIENT_SUPPORTS_SAMPLING_PROPERTY,
MCP_CLIENT_SUPPORTS_SAMPLING_TOOLS_PROPERTY,
MCP_CLIENT_ELICITATION_MODE_PROPERTY,
MCP_CLIENT_SUPPORTS_APPS_PROPERTY,
MCP_CLIENT_SUPPORTS_TASKS_PROPERTY,
MCP_CLIENT_SUPPORTS_OAUTH_CLIENT_CREDENTIALS_PROPERTY,
MCP_CLIENT_SUPPORTS_ENTERPRISE_AUTH_PROPERTY,
PostHogMCPAnalyticsProperty.ClientName,
PostHogMCPAnalyticsProperty.ClientVersion,
PostHogMCPAnalyticsProperty.DurationMs,
Expand Down Expand Up @@ -141,6 +183,70 @@ function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === "object" && !Array.isArray(value);
}

function hasOwn(record: Record<string, unknown>, key: string): boolean {
return Object.prototype.hasOwnProperty.call(record, key);
}

/**
* Reduces the client-controlled initialize capability map to bounded analytics.
* Presence is the signal: MCP uses empty objects to declare baseline support.
*/
export function clientCapabilityAnalyticsFromInitialize(
body: unknown,
): McpClientCapabilityAnalytics | null {
if (
!isRecord(body) ||
body.method !== "initialize" ||
!isRecord(body.params)
) {
return null;
}

const capabilities = isRecord(body.params.capabilities)
? body.params.capabilities
: {};
const sampling = isRecord(capabilities.sampling)
? capabilities.sampling
: null;
const elicitation = isRecord(capabilities.elicitation)
? capabilities.elicitation
: null;
const extensions = isRecord(capabilities.extensions)
? capabilities.extensions
: null;

const hasElicitationForm =
elicitation !== null &&
(hasOwn(elicitation, "form") || !hasOwn(elicitation, "url"));
const hasElicitationUrl = elicitation !== null && hasOwn(elicitation, "url");
let elicitationMode: McpClientElicitationMode = "none";
if (hasElicitationForm) {
elicitationMode = hasElicitationUrl ? "form_and_url" : "form";
} else if (hasElicitationUrl) {
elicitationMode = "url";
}

const properties: McpClientCapabilityAnalytics = {
[MCP_CLIENT_SUPPORTS_SAMPLING_PROPERTY]: sampling !== null,
[MCP_CLIENT_SUPPORTS_SAMPLING_TOOLS_PROPERTY]:
sampling !== null && hasOwn(sampling, "tools"),
[MCP_CLIENT_ELICITATION_MODE_PROPERTY]: elicitationMode,
[MCP_CLIENT_SUPPORTS_APPS_PROPERTY]: false,
[MCP_CLIENT_SUPPORTS_TASKS_PROPERTY]: hasOwn(capabilities, "tasks"),
[MCP_CLIENT_SUPPORTS_OAUTH_CLIENT_CREDENTIALS_PROPERTY]: false,
[MCP_CLIENT_SUPPORTS_ENTERPRISE_AUTH_PROPERTY]: false,
};

for (const [extension, property] of Object.entries(
CLIENT_EXTENSION_PROPERTIES,
)) {
if (extensions && hasOwn(extensions, extension))
properties[property] = true;
}

return properties;
}

function hasNonEmptyParam(
args: Record<string, unknown> | undefined,
key: string,
Expand Down Expand Up @@ -277,6 +383,15 @@ function connectionAnalyticsContext(extra: unknown) {
return authExtra?.connectionAnalytics;
}

function clientCapabilityAnalyticsContext(extra: unknown) {
const authInfo = (extra as { authInfo?: { extra?: unknown } } | undefined)
?.authInfo;
const authExtra = authInfo?.extra as
| { clientCapabilityAnalytics?: McpClientCapabilityAnalytics }
| undefined;
return authExtra?.clientCapabilityAnalytics;
}

// The route resolves the Kernel connection context at auth time and attaches it to
// authInfo.extra on every request, so reading the org id out of the request extras
// adds no I/O.
Expand Down Expand Up @@ -435,6 +550,8 @@ export function instrumentMcpAnalytics(
if (request.method === "initialize") {
const context = connectionAnalyticsContext(extra);
if (context) properties[ANALYTICS_CONTEXT_PROPERTY] = context;
const capabilities = clientCapabilityAnalyticsContext(extra);
if (capabilities) Object.assign(properties, capabilities);
}
return Object.keys(properties).length > 0 ? properties : null;
},
Expand Down
Loading