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
43 changes: 43 additions & 0 deletions src/app/[transport]/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: (
Expand All @@ -46,6 +48,7 @@ mock.module("@/lib/mcp/analytics", () => ({
server,
(analyticsClient as typeof client) ?? client,
),
isMcpAnalyticsEnabled: () => observeConnections || isMcpAnalyticsEnabled(),
captureMcpConnectionScopeFailure: (
...args: Parameters<typeof analytics.captureMcpConnectionScopeFailure>
) => {
Expand Down Expand Up @@ -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];
Expand Down Expand Up @@ -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<string, unknown> }[] = [];
analyticsClient = {
capture: (event: {
event: string;
properties: Record<string, unknown>;
}) => 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++) {
Expand Down Expand Up @@ -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")
Expand All @@ -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 () => {
Expand Down
16 changes: 12 additions & 4 deletions src/app/[transport]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -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.
Expand All @@ -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,
Expand Down
3 changes: 2 additions & 1 deletion src/lib/mcp/analytics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
};

Expand Down
17 changes: 11 additions & 6 deletions src/lib/mcp/auth-context.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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 () => {
Expand All @@ -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",
);
});
Expand Down
16 changes: 10 additions & 6 deletions src/lib/mcp/auth-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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" &&
Expand All @@ -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",
Expand Down
Loading