Skip to content
Merged
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
64 changes: 63 additions & 1 deletion 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 forceAnalyticsEnabled = 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: () => forceAnalyticsEnabled || isMcpAnalyticsEnabled(),
captureMcpConnectionScopeFailure: (
...args: Parameters<typeof analytics.captureMcpConnectionScopeFailure>
) => {
Expand Down Expand Up @@ -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];
Expand Down Expand Up @@ -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<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,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")
Expand All @@ -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 () => {
Expand All @@ -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 () => {
Expand Down Expand Up @@ -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,
},
]);
});
Expand All @@ -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 () => {
Expand All @@ -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,
},
]);
});
Expand All @@ -585,9 +647,9 @@ describe("connection scope failures through the handler", () => {
);
expect(captured).toEqual([
{
stage: "scope",
outcome: "invalid",
credentialType: "api_key",
upstreamStatusCode: undefined,
},
]);
});
Expand Down
51 changes: 35 additions & 16 deletions src/app/[transport]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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,
Expand All @@ -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;
Expand All @@ -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({
Expand All @@ -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: {
Expand Down Expand Up @@ -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.
Expand All @@ -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,
Expand Down
6 changes: 5 additions & 1 deletion src/lib/mcp/analytics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -727,9 +727,11 @@ describe("captureMcpConnectionScopeFailure", () => {

captureMcpConnectionScopeFailure(
{
stage: "scope",
outcome: "rejected",
credentialType: "api_key",
upstreamStatusCode: 403,
responseStatusCode: 403,
},
fakePosthog,
);
Expand All @@ -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,
},
},
]);
Expand All @@ -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();
Expand Down
18 changes: 14 additions & 4 deletions 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 @@ -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 =
Expand Down Expand Up @@ -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 {
Expand Down
Loading
Loading