Skip to content

Return 401 instead of 500 when the Kernel API rejects a credential - #156

Merged
masnwilliams merged 4 commits into
mainfrom
hypeship/mcp-auth-context-status
Aug 13, 2026
Merged

Return 401 instead of 500 when the Kernel API rejects a credential#156
masnwilliams merged 4 commits into
mainfrom
hypeship/mcp-auth-context-status

Conversation

@masnwilliams

@masnwilliams masnwilliams commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator

Summary

Scope resolution collapsed three different outcomes into null, and the route treated all of them as a server fault:

if (!connectionContext) {
  throw new Error("Unable to resolve Kernel connection scope");
}

So when /auth/context answers 401, the caller gets a 500. A client with an invalid, revoked, or wrong-project credential is told the server is broken, and their credential problem is counted as our 5xx rate.

Over a 3h window, /auth/context calls from the MCP server returned 200 × 8,110 and 401 × 1,017, with no 5xx and no timeouts. In one 30-minute window there were 110 upstream 401s against 110 occurrences of Unable to resolve Kernel connection scope — one 401 produces one 500. Measured at 20.4% error rate on /[transport] during one alert window.

What changed

resolveMcpConnectionContext now reports why it has no scope instead of returning null:

outcome cause response
rejected upstream 4xx — the credential was refused 401 invalid_token
unavailable transient failure with no cached scope to fall back on 503 + Retry-After
invalid malformed auth context, or a scope we cannot normalize 500 (unchanged)

Transient classification is unchanged — 408, 429, 5xx, and errors with no status are still transient, and still prefer a cached scope when one exists. The only behavior change is what happens when there is nothing to fall back on.

invalid deliberately still throws. A response we cannot normalize is our bug, and it should keep surfacing as one.

Observability

Scope resolution runs before the request reaches the instrumented McpServer, so these failures emitted no $mcp_* event at all — error rate, tool calls, and session counts looked normal through a spike, and the only signal was a Vercel error-anomaly alert. Added mcp_connection_scope_failure, following the existing captureOAuthTokenExchange pattern, carrying the outcome, credential type, and upstream status. No token, credential, or org identifier is recorded.

Tests

bun test — 210 pass. Resolver classification is covered per status (401, 403 → rejected; 408, 429, 500, 502, 503 → unavailable), along with the existing cache and fallback behavior. The route-level mapping is asserted on the response itself: status, WWW-Authenticate, Retry-After, and body. The classification assertions fail against pre-fix code.

Note

All rejections answer invalid_token, including 403. Every rejection observed in production is a 401, so a separate insufficient_scope mapping would be untested guesswork; worth revisiting if 403s appear.


Note

Medium Risk
Changes authentication and scope-resolution error handling on every MCP request; behavior is intentional but affects client-visible status codes and cache invalidation on rejections.

Overview
Fixes MCP transport requests where a Kernel /auth/context rejection (especially 401) was turned into a 500 because scope resolution only returned null and the route always threw.

resolveMcpConnectionContext now returns a discriminated result (ok | rejected | unavailable | invalid) with classifyAuthContextError mapping upstream 401/403/404 to rejected, retryable statuses and transport errors to unavailable, and malformed/unclassified cases to invalid. Stale-cache fallback on transient failures is unchanged.

The MCP route uses connectionScopeFailureResponse to answer clients with 401 invalid_token, 403 insufficient_scope, 404 project_not_found, or 503 + Retry-After; only invalid still throws. captureMcpConnectionScopeFailure records outcome, credential type, and upstream status (no secrets).

Route and resolver tests cover status mapping, analytics capture, and classification per HTTP status.

Reviewed by Cursor Bugbot for commit 5ca48b7. Bugbot is set up for automated code reviews on this repo. Configure here.

@vercel

vercel Bot commented Aug 12, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
mcp Ready Ready Preview Aug 13, 2026 12:17am

@masnwilliams

Copy link
Copy Markdown
Collaborator Author

Verified against the deployed preview with a credential the Kernel API rejects — same request, both environments:

# production today
HTTP/2 500
(empty body)

# this branch
HTTP/2 401
www-authenticate: Bearer realm="OAuth", error="invalid_token", error_description="The Kernel API rejected this credential"
{"error":"invalid_token","error_description":"The Kernel API rejected this credential"}

The client now gets a status and a body that name the actual problem, instead of an empty server error.

@yummybomb yummybomb left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the core 401 fix is solid, just a few comments

blockers

  • only upstream 401 should become invalid_token. Kernel returns 403 for a valid credential scoped to another project and 404 for a missing/inactive project. Mapping either to 401 can cause clients to discard a valid credential. Map 403 to insufficient_scope and keep 404 distinct.
  • every statusless exception currently becomes a 503, including unexpected plain errors such as response-parsing bugs. Special-case known SDK transport/timeout errors as unavailable, but classify unknown exceptions as invalid so genuine server bugs still surface as 500s.

non-blocking

  • unavailable drops the upstream status, so 408/429/5xx emit upstream_status_code: undefined. Preserve statusCode?: number on that variant; add a separate error kind if timeout versus abort needs distinction.

test coverage

  • route.test.ts only tests the exported response mapper. It doesn’t execute the handler, verify that invalid still throws, or prove captureMcpConnectionScopeFailure fires. Please add handler-path coverage and a focused analytics capture test.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 3f3cc66. Configure here.

Comment thread src/app/[transport]/route.test.ts
@masnwilliams

Copy link
Copy Markdown
Collaborator Author

addressed all four review points:

  • only upstream 401 returns invalid_token; 403 returns insufficient_scope, and 404 remains a distinct project_not_found response without WWW-Authenticate.
  • only known SDK connection/timeout/abort errors are retryable when statusless; unknown exceptions remain invalid and throw through the handler.
  • unavailable now preserves statusCode, including in failure analytics.
  • added real POST handler-path coverage for rejected, unavailable, and invalid outcomes, plus focused analytics capture coverage. the handler test now avoids Redis and restores its shared client dependency after every test.

@yummybomb yummybomb left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lgtm — the failure classification, response mappings, analytics, and handler-path coverage all look solid.

@masnwilliams
masnwilliams merged commit 72faacf into main Aug 13, 2026
10 checks passed
@masnwilliams
masnwilliams deleted the hypeship/mcp-auth-context-status branch August 13, 2026 00:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants