diff --git a/src/app/[transport]/route.test.ts b/src/app/[transport]/route.test.ts index 91b5a7f9..94c1fc45 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 forceAnalyticsEnabled = false; mock.module("@/lib/mcp/analytics", () => ({ ...analytics, instrumentMcpAnalytics: ( @@ -46,6 +48,7 @@ mock.module("@/lib/mcp/analytics", () => ({ server, (analyticsClient as typeof client) ?? client, ), + isMcpAnalyticsEnabled: () => forceAnalyticsEnabled || isMcpAnalyticsEnabled(), captureMcpConnectionScopeFailure: ( ...args: Parameters ) => { @@ -197,6 +200,7 @@ describe("native OAuth credentials", () => { afterEach(() => { appMarkers = null; analyticsClient = null; + forceAnalyticsEnabled = 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 () => { + forceAnalyticsEnabled = 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,16 @@ describe("native OAuth credentials", () => { error: "invalid_token", error_description: "Invalid or expired access token", }); + expect(captured).toEqual([ + { + stage: "authentication", + outcome: "rejected", + credentialType: "native_oauth", + upstreamStatusCode: undefined, + responseStatusCode: 401, + }, + ]); + captured.length = 0; fetchSpy.mockImplementation((async (input: string | URL | Request) => String(input).endsWith("/token") @@ -361,6 +400,15 @@ describe("native OAuth credentials", () => { "This credential's scope does not cover this request", ); expect(kernelTokens).toEqual([]); + expect(captured).toEqual([ + { + stage: "authentication", + outcome: "rejected", + credentialType: "native_oauth", + upstreamStatusCode: undefined, + responseStatusCode: 403, + }, + ]); }); test("issuer outages are retryable 503s", async () => { @@ -371,6 +419,15 @@ describe("native OAuth credentials", () => { expect(response.status).toBe(503); expect(response.headers.get("Retry-After")).toBe("1"); expect(kernelTokens).toEqual([]); + expect(captured).toEqual([ + { + stage: "authentication", + outcome: "unavailable", + credentialType: "native_oauth", + upstreamStatusCode: undefined, + responseStatusCode: 503, + }, + ]); }); test("post-authentication bugs propagate as server errors", async () => { @@ -540,9 +597,11 @@ describe("connection scope failures through the handler", () => { }); expect(captured).toEqual([ { + stage: "scope", outcome: "rejected", credentialType: "api_key", upstreamStatusCode: 401, + responseStatusCode: 401, }, ]); }); @@ -557,6 +616,7 @@ describe("connection scope failures through the handler", () => { expect(response.status).toBe(403); expect((await response.json()).error).toBe("insufficient_scope"); expect(captured[0]?.upstreamStatusCode).toBe(403); + expect(captured[0]?.responseStatusCode).toBe(403); }); test("answers an upstream outage with a retryable 503", async () => { @@ -570,9 +630,11 @@ describe("connection scope failures through the handler", () => { expect(response.headers.get("Retry-After")).toBe("1"); expect(captured).toEqual([ { + stage: "scope", outcome: "unavailable", credentialType: "api_key", upstreamStatusCode: 502, + responseStatusCode: 503, }, ]); }); @@ -585,9 +647,9 @@ describe("connection scope failures through the handler", () => { ); expect(captured).toEqual([ { + stage: "scope", outcome: "invalid", credentialType: "api_key", - upstreamStatusCode: undefined, }, ]); }); diff --git a/src/app/[transport]/route.ts b/src/app/[transport]/route.ts index d9fede43..89ffce3f 100644 --- a/src/app/[transport]/route.ts +++ b/src/app/[transport]/route.ts @@ -17,11 +17,13 @@ import { flushMcpAnalytics, instrumentMcpAnalytics, isMcpAnalyticsEnabled, + type McpConnectionScopeFailureAnalytics, } from "@/lib/mcp/analytics"; 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"; @@ -190,6 +192,33 @@ type AuthInfoExtra = { clerkToken: string | null; }; +// A refused connection never reaches the instrumented server, so this +// capture is its only analytics record. +function refuseConnection( + req: Request, + stage: McpConnectionScopeFailureAnalytics["stage"], + credentialType: McpCredentialType, + failure: McpConnectionContextFailure, +): Response { + if (failure.status === "invalid") { + captureMcpConnectionScopeFailure({ + stage, + outcome: failure.status, + credentialType, + }); + throw new Error("Unable to resolve Kernel connection scope"); + } + const response = connectionScopeFailureResponse(req, failure); + captureMcpConnectionScopeFailure({ + stage, + outcome: failure.status, + credentialType, + upstreamStatusCode: stage === "scope" ? failure.statusCode : undefined, + responseStatusCode: response.status, + }); + return response; +} + async function handleMcpRequestWithIdentity({ req, token, @@ -207,7 +236,7 @@ async function handleMcpRequestWithIdentity({ authSubject: string; scopes: string[]; authInfoExtra: AuthInfoExtra; - credentialType: "api_key" | "oauth"; + credentialType: McpCredentialType; transportSessionId: string | null; connectionContextCacheIdentity?: string; entitlementsCacheCredential?: string; @@ -225,18 +254,8 @@ async function handleMcpRequestWithIdentity({ cacheIdentity: connectionContextCacheIdentity, }), ]); - if (connection.status !== "ok") { - captureMcpConnectionScopeFailure({ - outcome: connection.status, - credentialType, - upstreamStatusCode: - connection.status === "invalid" ? undefined : connection.statusCode, - }); - if (connection.status === "invalid") { - throw new Error("Unable to resolve Kernel connection scope"); - } - return connectionScopeFailureResponse(req, connection); - } + if (connection.status !== "ok") + return refuseConnection(req, "scope", credentialType, connection); // Resolve entitlements only for tool discovery and gated tool calls. const entitlements = (await requestRequiresMcpEntitlements(req)) ? await resolveMcpEntitlements({ @@ -255,7 +274,7 @@ async function handleMcpRequestWithIdentity({ const connectionContext = connection.context; const connectionAnalytics = observeConnection && isMcpAnalyticsEnabled() - ? connectionAnalyticsFromContext(connectionContext) + ? connectionAnalyticsFromContext(connectionContext, credentialType) : null; return handler.fetch(req, { authInfo: { @@ -294,7 +313,7 @@ async function handleAuthenticatedRequest( if (isNativeOAuthCredential(token)) { const native = await authenticateNativeOAuth(token); if (native.status !== "ok") - return connectionScopeFailureResponse(req, native); + return refuseConnection(req, "authentication", "native_oauth", 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 +331,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.test.ts b/src/lib/mcp/analytics.test.ts index a08eb7f7..d36f7a55 100644 --- a/src/lib/mcp/analytics.test.ts +++ b/src/lib/mcp/analytics.test.ts @@ -727,9 +727,11 @@ describe("captureMcpConnectionScopeFailure", () => { captureMcpConnectionScopeFailure( { + stage: "scope", outcome: "rejected", credentialType: "api_key", upstreamStatusCode: 403, + responseStatusCode: 403, }, fakePosthog, ); @@ -740,9 +742,11 @@ describe("captureMcpConnectionScopeFailure", () => { event: MCP_CONNECTION_SCOPE_FAILURE_EVENT, properties: { $process_person_profile: false, + connection_failure_stage: "scope", connection_scope_outcome: "rejected", connection_credential_type: "api_key", upstream_status_code: 403, + response_status_code: 403, }, }, ]); @@ -751,7 +755,7 @@ describe("captureMcpConnectionScopeFailure", () => { test("is a no-op without a configured client", () => { expect(() => captureMcpConnectionScopeFailure( - { outcome: "unavailable", credentialType: "oauth" }, + { stage: "scope", outcome: "unavailable", credentialType: "oauth" }, null, ), ).not.toThrow(); diff --git a/src/lib/mcp/analytics.ts b/src/lib/mcp/analytics.ts index bf1f5ffe..c871cffd 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 { @@ -83,13 +84,20 @@ export type OAuthTokenExchangeAnalytics = { export const OAUTH_TOKEN_EXCHANGE_EVENT = "oauth_token_exchange"; -// Scope resolution runs before a request reaches the instrumented server, so a -// connection that never gets a scope emits no $mcp_* event. This is the only -// record of it. +// A connection the server refuses never reaches the instrumented server, so it +// emits no $mcp_* event and this is its only record. Recorded for native OAuth +// authentication failures and for connection-scope refusals from the Kernel +// API. Clerk token verification failures are not recorded. export type McpConnectionScopeFailureAnalytics = { + // "authentication": this server refused a native token (local verification + // or token exchange). "scope": the Kernel API refused the connection. + stage: "authentication" | "scope"; outcome: "rejected" | "unavailable" | "invalid"; - credentialType: "api_key" | "oauth"; + credentialType: McpCredentialType; + // Status the Kernel API returned from /auth/context. Only set for "scope". upstreamStatusCode?: number; + // Status this server answered with. Unset for "invalid", which throws. + responseStatusCode?: number; }; export const MCP_CONNECTION_SCOPE_FAILURE_EVENT = @@ -799,9 +807,11 @@ export function captureMcpConnectionScopeFailure( if (!client) return; const properties = { + connection_failure_stage: failure.stage, connection_scope_outcome: failure.outcome, connection_credential_type: failure.credentialType, upstream_status_code: failure.upstreamStatusCode, + response_status_code: failure.responseStatusCode, }; try { diff --git a/src/lib/mcp/auth-context.test.ts b/src/lib/mcp/auth-context.test.ts index 295ab933..44e4a14f 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,12 +148,44 @@ 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", ); }); + test("reports nothing when the credential type and auth context disagree", async () => { + const oauthContext = expectResolved( + await resolveMcpConnectionContext({ + token: "jwt.secret.value", + dependencies: dependencies( + response({ + method: "jwt", + source: "oauth", + principalType: "user", + principalId: "user_kernel_123", + }), + ), + }), + ); + const apiKeyContext = expectResolved( + await resolveMcpConnectionContext({ + token: "sk_secret", + dependencies: dependencies(response({})), + }), + ); + + expect(connectionAnalyticsFromContext(oauthContext, "api_key")).toBeNull(); + expect( + connectionAnalyticsFromContext(apiKeyContext, "native_oauth"), + ).toBeNull(); + }); + test("normalizes KERNEL_PROJECT as a server pin", () => { const scope = connectionScopeFromAuthContext( response({ effectiveProjectId: "project_pinned" }), diff --git a/src/lib/mcp/auth-context.ts b/src/lib/mcp/auth-context.ts index 5963d76b..5e3753fd 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" && @@ -275,10 +279,11 @@ export function connectionAnalyticsFromContext({ authContext.authentication.method === "jwt" && authContext.authentication.source === "oauth" && authContext.principal.type === "user"; - if (!isApiKey && !isOAuth) return null; + // The route decides the credential type; the context must agree with it. + if (!(credentialType === "api_key" ? isApiKey : isOAuth)) return null; return { - authMethod: isApiKey ? "api_key" : "oauth", + authMethod: credentialType, credentialScope: authContext.authorization.credential_scope.project_id ? "project" : "organization",