diff --git a/src/app/[transport]/route.ts b/src/app/[transport]/route.ts index e32f279..d67f342 100644 --- a/src/app/[transport]/route.ts +++ b/src/app/[transport]/route.ts @@ -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, @@ -130,6 +132,7 @@ async function handleMcpRequestWithIdentity({ transportSessionId, connectionContextCacheIdentity, observeConnection, + clientCapabilityAnalytics, }: { req: NextRequest; token: string; @@ -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, { @@ -180,6 +184,7 @@ async function handleMcpRequestWithIdentity({ ...authInfoExtra, connectionContext, connectionAnalytics, + clientCapabilityAnalytics, }, }), { @@ -194,6 +199,7 @@ async function handleAuthenticatedRequest( req: NextRequest, transportSessionId: string | null = null, observeConnection = false, + clientCapabilityAnalytics: McpClientCapabilityAnalytics | null = null, ): Promise { const authHeader = req.headers.get("Authorization"); const token = authHeader?.startsWith("Bearer ") @@ -219,6 +225,7 @@ async function handleAuthenticatedRequest( credentialType: "api_key", transportSessionId, observeConnection, + clientCapabilityAnalytics, }); } @@ -256,6 +263,7 @@ async function handleAuthenticatedRequest( ? `${authSubject}\0${transportSessionId}` : undefined, observeConnection, + clientCapabilityAnalytics, }); } @@ -286,6 +294,9 @@ export async function POST(req: NextRequest): Promise { } 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 @@ -310,6 +321,7 @@ export async function POST(req: NextRequest): Promise { }), session?.id ?? null, isInitialize, + clientCapabilityAnalytics, ); if (!session) return response; diff --git a/src/lib/mcp/analytics.test.ts b/src/lib/mcp/analytics.test.ts index d24c845..3716a63 100644 --- a/src/lib/mcp/analytics.test.ts +++ b/src/lib/mcp/analytics.test.ts @@ -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, @@ -19,6 +27,85 @@ import { const privateContextProperty = "__mcp_connection_analytics_context"; +function initialize(capabilities: Record) { + 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: { @@ -459,12 +546,17 @@ describe("instrumentMcpAnalytics (SDK integration)", () => { params: Record, ) { 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: {} }, @@ -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)); } @@ -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", {}); @@ -513,6 +609,14 @@ describe("instrumentMcpAnalytics (SDK integration)", () => { properties: Record; }; + 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(); diff --git a/src/lib/mcp/analytics.ts b/src/lib/mcp/analytics.ts index 92a2707..90475f7 100644 --- a/src/lib/mcp/analytics.ts +++ b/src/lib/mcp/analytics.ts @@ -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 — @@ -84,6 +119,13 @@ const SENT_PROPERTIES = new Set([ "$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, @@ -141,6 +183,70 @@ function isRecord(value: unknown): value is Record { return !!value && typeof value === "object" && !Array.isArray(value); } +function hasOwn(record: Record, 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 | undefined, key: string, @@ -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. @@ -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; },