diff --git a/src/app/[transport]/route.test.ts b/src/app/[transport]/route.test.ts index 91b5a7f..3ea6efa 100644 --- a/src/app/[transport]/route.test.ts +++ b/src/app/[transport]/route.test.ts @@ -36,7 +36,9 @@ const analytics = await import("@/lib/mcp/analytics"); const captureMcpConnectionScopeFailure = analytics.captureMcpConnectionScopeFailure; const instrumentMcpAnalytics = analytics.instrumentMcpAnalytics; +const isMcpAnalyticsEnabled = analytics.isMcpAnalyticsEnabled; let analyticsClient: unknown = null; +let observeConnections = false; mock.module("@/lib/mcp/analytics", () => ({ ...analytics, instrumentMcpAnalytics: ( @@ -46,6 +48,7 @@ mock.module("@/lib/mcp/analytics", () => ({ server, (analyticsClient as typeof client) ?? client, ), + isMcpAnalyticsEnabled: () => observeConnections || isMcpAnalyticsEnabled(), captureMcpConnectionScopeFailure: ( ...args: Parameters ) => { @@ -197,6 +200,7 @@ describe("native OAuth credentials", () => { afterEach(() => { appMarkers = null; analyticsClient = null; + observeConnections = false; fetchSpy.mockRestore(); for (const key of envKeys) if (savedEnv[key] === undefined) delete process.env[key]; @@ -287,6 +291,31 @@ describe("native OAuth credentials", () => { ).toBe(1); }); + test("report connections as native OAuth in analytics", async () => { + observeConnections = true; + const events: { event: string; properties: Record }[] = []; + analyticsClient = { + capture: (event: { + event: string; + properties: Record; + }) => events.push(event), + }; + const response = await POST( + mcpRequest(await issuer.parent(), "initialize", { + protocolVersion: "2025-06-18", + capabilities: {}, + clientInfo: { name: "test-client", version: "1.0.0" }, + }), + ); + expect(response.status).toBe(200); + await response.text(); + await new Promise((resolve) => setTimeout(resolve, 50)); + const initialize = events.find( + (event) => event.event === "$mcp_initialize", + ); + expect(initialize?.properties.$mcp_auth_method).toBe("native_oauth"); + }); + test("reuse one exchange per MCP token and re-resolve auth context each request", async () => { const parent = await issuer.parent(); for (let n = 0; n < 3; n++) { @@ -347,6 +376,13 @@ describe("native OAuth credentials", () => { error: "invalid_token", error_description: "Invalid or expired access token", }); + expect(captured).toEqual([ + { + outcome: "rejected", + credentialType: "native_oauth", + upstreamStatusCode: 401, + }, + ]); fetchSpy.mockImplementation((async (input: string | URL | Request) => String(input).endsWith("/token") @@ -371,6 +407,13 @@ describe("native OAuth credentials", () => { expect(response.status).toBe(503); expect(response.headers.get("Retry-After")).toBe("1"); expect(kernelTokens).toEqual([]); + expect(captured).toEqual([ + { + outcome: "unavailable", + credentialType: "native_oauth", + upstreamStatusCode: undefined, + }, + ]); }); test("post-authentication bugs propagate as server errors", async () => { diff --git a/src/app/[transport]/route.ts b/src/app/[transport]/route.ts index d9fede4..09dcfb5 100644 --- a/src/app/[transport]/route.ts +++ b/src/app/[transport]/route.ts @@ -22,6 +22,7 @@ import { connectionAnalyticsFromContext, resolveMcpConnectionContext, type McpConnectionContextFailure, + type McpCredentialType, } from "@/lib/mcp/auth-context"; import { mcpAppsAuthSubject } from "@/lib/mcp-apps-marker"; import { requestUsesMcpApps } from "@/lib/mcp-apps-request"; @@ -207,7 +208,7 @@ async function handleMcpRequestWithIdentity({ authSubject: string; scopes: string[]; authInfoExtra: AuthInfoExtra; - credentialType: "api_key" | "oauth"; + credentialType: McpCredentialType; transportSessionId: string | null; connectionContextCacheIdentity?: string; entitlementsCacheCredential?: string; @@ -255,7 +256,7 @@ async function handleMcpRequestWithIdentity({ const connectionContext = connection.context; const connectionAnalytics = observeConnection && isMcpAnalyticsEnabled() - ? connectionAnalyticsFromContext(connectionContext) + ? connectionAnalyticsFromContext(connectionContext, credentialType) : null; return handler.fetch(req, { authInfo: { @@ -293,8 +294,15 @@ async function handleAuthenticatedRequest( if (isNativeOAuthCredential(token)) { const native = await authenticateNativeOAuth(token); - if (native.status !== "ok") + if (native.status !== "ok") { + captureMcpConnectionScopeFailure({ + outcome: native.status, + credentialType: "native_oauth", + upstreamStatusCode: + native.status === "rejected" ? native.statusCode : undefined, + }); return connectionScopeFailureResponse(req, native); + } const { credential } = native; // The exchanged token rotates; identity is the verified subject, and the // request ends when the exchanged token does, streams included. @@ -312,7 +320,7 @@ async function handleAuthenticatedRequest( }), scopes: credential.scopes, authInfoExtra: { userId: credential.subject, clerkToken: null }, - credentialType: "oauth", + credentialType: "native_oauth", transportSessionId, entitlementsCacheCredential: credential.grantId, observeConnection, diff --git a/src/lib/mcp/analytics.ts b/src/lib/mcp/analytics.ts index bf1f5ff..3a274b2 100644 --- a/src/lib/mcp/analytics.ts +++ b/src/lib/mcp/analytics.ts @@ -20,6 +20,7 @@ import { PostHog } from "posthog-node"; import type { McpConnectionAnalyticsContext, McpConnectionContext, + McpCredentialType, } from "@/lib/mcp/auth-context"; import { MCP_INTENT_ARGUMENT_DESCRIPTION } from "@/lib/mcp/analytics-context"; import { @@ -88,7 +89,7 @@ export const OAUTH_TOKEN_EXCHANGE_EVENT = "oauth_token_exchange"; // record of it. export type McpConnectionScopeFailureAnalytics = { outcome: "rejected" | "unavailable" | "invalid"; - credentialType: "api_key" | "oauth"; + credentialType: McpCredentialType; upstreamStatusCode?: number; }; diff --git a/src/lib/mcp/auth-context.test.ts b/src/lib/mcp/auth-context.test.ts index 295ab93..1fea648 100644 --- a/src/lib/mcp/auth-context.test.ts +++ b/src/lib/mcp/auth-context.test.ts @@ -97,7 +97,7 @@ describe("resolveMcpConnectionContext", () => { projectId: null, source: "credential", }); - const analytics = connectionAnalyticsFromContext(context); + const analytics = connectionAnalyticsFromContext(context, "api_key"); expect(analytics).toEqual({ authMethod: "api_key", credentialScope: "organization", @@ -128,9 +128,9 @@ describe("resolveMcpConnectionContext", () => { projectId: "project_123", source: "credential", }); - expect(connectionAnalyticsFromContext(context)?.credentialScope).toBe( - "project", - ); + expect( + connectionAnalyticsFromContext(context, "api_key")?.credentialScope, + ).toBe("project"); }); test("uses the canonical OAuth user principal for analytics", async () => { @@ -148,8 +148,13 @@ describe("resolveMcpConnectionContext", () => { }), ); - expect(connectionAnalyticsFromContext(context)?.authMethod).toBe("oauth"); - expect(connectionAnalyticsFromContext(context)?.userId).toBe( + expect(connectionAnalyticsFromContext(context, "oauth")?.authMethod).toBe( + "oauth", + ); + expect( + connectionAnalyticsFromContext(context, "native_oauth")?.authMethod, + ).toBe("native_oauth"); + expect(connectionAnalyticsFromContext(context, "oauth")?.userId).toBe( "user_kernel_123", ); }); diff --git a/src/lib/mcp/auth-context.ts b/src/lib/mcp/auth-context.ts index 5963d76..77b46a0 100644 --- a/src/lib/mcp/auth-context.ts +++ b/src/lib/mcp/auth-context.ts @@ -45,8 +45,12 @@ export type McpConnectionContext = { scope: ConnectionScope; }; +// Which credential path authenticated the connection. "oauth" is a Clerk-issued +// token; "native_oauth" one issued by the Kernel authorization server. +export type McpCredentialType = "api_key" | "oauth" | "native_oauth"; + export type McpConnectionAnalyticsContext = { - authMethod: "api_key" | "oauth"; + authMethod: McpCredentialType; credentialScope: "organization" | "project"; connectionScope: "organization" | "project"; scopeSource: "credential" | "server_pin"; @@ -263,10 +267,10 @@ export function expireMcpConnectionContextCacheForTests() { for (const entry of connectionContextCache.values()) entry.expiresAt = 0; } -export function connectionAnalyticsFromContext({ - authContext, - scope, -}: McpConnectionContext): McpConnectionAnalyticsContext | null { +export function connectionAnalyticsFromContext( + { authContext, scope }: McpConnectionContext, + credentialType: McpCredentialType, +): McpConnectionAnalyticsContext | null { const isApiKey = authContext.authentication.method === "api_key" && authContext.authentication.source === "api_key" && @@ -278,7 +282,7 @@ export function connectionAnalyticsFromContext({ if (!isApiKey && !isOAuth) return null; return { - authMethod: isApiKey ? "api_key" : "oauth", + authMethod: isApiKey ? "api_key" : credentialType, credentialScope: authContext.authorization.credential_scope.project_id ? "project" : "organization",