From 5b5a8970a7553d405a6e9b3371cc1874b72aab60 Mon Sep 17 00:00:00 2001 From: rgarcia <72655+rgarcia@users.noreply.github.com> Date: Thu, 12 Nov 2026 21:04:40 +0000 Subject: [PATCH 1/2] Tag native OAuth connections and failures in MCP analytics --- src/app/[transport]/route.test.ts | 43 +++++++++++++++++++++++++++++++ src/app/[transport]/route.ts | 16 +++++++++--- src/lib/mcp/analytics.ts | 3 ++- src/lib/mcp/auth-context.test.ts | 17 +++++++----- src/lib/mcp/auth-context.ts | 16 +++++++----- 5 files changed, 78 insertions(+), 17 deletions(-) diff --git a/src/app/[transport]/route.test.ts b/src/app/[transport]/route.test.ts index 91b5a7f9..3ea6efa9 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 d9fede43..09dcfb5f 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 bf1f5ffe..3a274b2f 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 295ab933..1fea648e 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 5963d76b..77b46a02 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", From 210adaa32210dd0093486238a8b6d662e6cd35fc Mon Sep 17 00:00:00 2001 From: rgarcia <72655+rgarcia@users.noreply.github.com> Date: Fri, 13 Nov 2026 18:01:06 +0000 Subject: [PATCH 2/2] Record the failure stage and response status for refused MCP connections --- src/app/[transport]/route.test.ts | 31 ++++++++++++++---- src/app/[transport]/route.ts | 53 +++++++++++++++++++------------ src/lib/mcp/analytics.test.ts | 6 +++- src/lib/mcp/analytics.ts | 15 +++++++-- src/lib/mcp/auth-context.test.ts | 27 ++++++++++++++++ src/lib/mcp/auth-context.ts | 5 +-- 6 files changed, 104 insertions(+), 33 deletions(-) diff --git a/src/app/[transport]/route.test.ts b/src/app/[transport]/route.test.ts index 3ea6efa9..94c1fc45 100644 --- a/src/app/[transport]/route.test.ts +++ b/src/app/[transport]/route.test.ts @@ -38,7 +38,7 @@ const captureMcpConnectionScopeFailure = const instrumentMcpAnalytics = analytics.instrumentMcpAnalytics; const isMcpAnalyticsEnabled = analytics.isMcpAnalyticsEnabled; let analyticsClient: unknown = null; -let observeConnections = false; +let forceAnalyticsEnabled = false; mock.module("@/lib/mcp/analytics", () => ({ ...analytics, instrumentMcpAnalytics: ( @@ -48,7 +48,7 @@ mock.module("@/lib/mcp/analytics", () => ({ server, (analyticsClient as typeof client) ?? client, ), - isMcpAnalyticsEnabled: () => observeConnections || isMcpAnalyticsEnabled(), + isMcpAnalyticsEnabled: () => forceAnalyticsEnabled || isMcpAnalyticsEnabled(), captureMcpConnectionScopeFailure: ( ...args: Parameters ) => { @@ -200,7 +200,7 @@ describe("native OAuth credentials", () => { afterEach(() => { appMarkers = null; analyticsClient = null; - observeConnections = false; + forceAnalyticsEnabled = false; fetchSpy.mockRestore(); for (const key of envKeys) if (savedEnv[key] === undefined) delete process.env[key]; @@ -292,7 +292,7 @@ describe("native OAuth credentials", () => { }); test("report connections as native OAuth in analytics", async () => { - observeConnections = true; + forceAnalyticsEnabled = true; const events: { event: string; properties: Record }[] = []; analyticsClient = { capture: (event: { @@ -378,11 +378,14 @@ describe("native OAuth credentials", () => { }); expect(captured).toEqual([ { + stage: "authentication", outcome: "rejected", credentialType: "native_oauth", - upstreamStatusCode: 401, + upstreamStatusCode: undefined, + responseStatusCode: 401, }, ]); + captured.length = 0; fetchSpy.mockImplementation((async (input: string | URL | Request) => String(input).endsWith("/token") @@ -397,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 () => { @@ -409,9 +421,11 @@ describe("native OAuth credentials", () => { expect(kernelTokens).toEqual([]); expect(captured).toEqual([ { + stage: "authentication", outcome: "unavailable", credentialType: "native_oauth", upstreamStatusCode: undefined, + responseStatusCode: 503, }, ]); }); @@ -583,9 +597,11 @@ describe("connection scope failures through the handler", () => { }); expect(captured).toEqual([ { + stage: "scope", outcome: "rejected", credentialType: "api_key", upstreamStatusCode: 401, + responseStatusCode: 401, }, ]); }); @@ -600,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 () => { @@ -613,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, }, ]); }); @@ -628,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 09dcfb5f..89ffce3f 100644 --- a/src/app/[transport]/route.ts +++ b/src/app/[transport]/route.ts @@ -17,6 +17,7 @@ import { flushMcpAnalytics, instrumentMcpAnalytics, isMcpAnalyticsEnabled, + type McpConnectionScopeFailureAnalytics, } from "@/lib/mcp/analytics"; import { connectionAnalyticsFromContext, @@ -191,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, @@ -226,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({ @@ -294,15 +312,8 @@ async function handleAuthenticatedRequest( if (isNativeOAuthCredential(token)) { const native = await authenticateNativeOAuth(token); - if (native.status !== "ok") { - captureMcpConnectionScopeFailure({ - outcome: native.status, - credentialType: "native_oauth", - upstreamStatusCode: - native.status === "rejected" ? native.statusCode : undefined, - }); - return connectionScopeFailureResponse(req, native); - } + if (native.status !== "ok") + 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. 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 3a274b2f..c871cffd 100644 --- a/src/lib/mcp/analytics.ts +++ b/src/lib/mcp/analytics.ts @@ -84,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: 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 = @@ -800,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 1fea648e..44e4a14f 100644 --- a/src/lib/mcp/auth-context.test.ts +++ b/src/lib/mcp/auth-context.test.ts @@ -159,6 +159,33 @@ describe("resolveMcpConnectionContext", () => { ); }); + 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 77b46a02..5e3753fd 100644 --- a/src/lib/mcp/auth-context.ts +++ b/src/lib/mcp/auth-context.ts @@ -279,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" : credentialType, + authMethod: credentialType, credentialScope: authContext.authorization.credential_scope.project_id ? "project" : "organization",