Skip to content

[server] return JSON-RPC error envelopes on auth failure#243

Open
txcfi-scott wants to merge 1 commit intoNateBJones-Projects:mainfrom
txcfi-scott:contrib/txcfi-scott/auth-error-envelope
Open

[server] return JSON-RPC error envelopes on auth failure#243
txcfi-scott wants to merge 1 commit intoNateBJones-Projects:mainfrom
txcfi-scott:contrib/txcfi-scott/auth-error-envelope

Conversation

@txcfi-scott
Copy link
Copy Markdown

Problem

The reference MCP server returns a bare HTTP 401 on auth failure:

return c.json({ error: "Invalid or missing access key" }, 401, corsHeaders);

Strict MCP clients that follow the JSON-RPC 2.0 wire spec — Codex CLI, Claude Code, and anything else that distinguishes transport faults from application errors — interpret a bare HTTP 4xx as a transport-level failure and tear the connection down rather than surfacing the auth error to the application layer. The practical consequence is that scenarios like "wrong key — prompt the user to update it" or "stale cached key — refetch and retry" can't be handled gracefully: the client sees a dead connection, not a recoverable error.

This bug bites anyone wiring a fresh OB1 deployment up to a strict client and rotating keys (or fat-fingering the first one).

Solution

Return HTTP 200 with a JSON-RPC 2.0 error envelope. The transport stays healthy, and the JSON-RPC layer carries the failure as an application-level error the client can handle:

{ "jsonrpc": "2.0", "error": { "code": -32001, "message": "Unauthorized: missing or invalid authentication." }, "id": <inbound id or null> }

-32001 lives in JSON-RPC's -32099..-32000 "implementation-defined server error" range and is the conventional code MCP servers use for "Unauthorized" in the wild. The inbound request id is best-effort extracted and echoed back so the response correlates with the call; malformed or missing bodies fall back to id: null.

Backward compatibility

  • Success path unchanged. Authenticated requests behave exactly as before — same StreamableHTTPTransport handoff, same Accept-header patch, same CORS.
  • Lenient clients still work. A client that doesn't validate JSON-RPC envelopes still gets a 200 with a parseable JSON body containing error. It can read the message or ignore it.
  • Strict clients now recover gracefully instead of dying on a transport fault.

Edge cases handled

  • Bodyless methods (GET / HEAD / DELETE): body read is skipped, id defaults to null.
  • Malformed JSON body: parse failure swallowed, id defaults to null.
  • Non-JSON-RPC body shape: missing/non-id-typed id falls back to null (only string | number | null are accepted, per the JSON-RPC 2.0 spec).
  • CORS preflight (OPTIONS): unchanged — still returns ok 200 with CORS headers before auth runs.
  • Body consumed on auth failure: safe — on the rejection path we return immediately and never forward to the transport, so consuming the body to read the id has no downstream effect.

Implementation

Three small helpers added next to the existing corsHeaders block:

  • readBodyText(req) — best-effort body read, returns null on bodyless methods or read failure.
  • extractJsonRpcId(bodyText) — best-effort JSON-RPC id extraction, returns null on anything malformed.
  • unauthorizedResponse(id) — builds the HTTP 200 + JSON-RPC error envelope with CORS headers attached.

The auth check in app.all("*", ...) calls those helpers in place of the bare c.json(...) 401.

Tested

  • deno check server/index.ts — no type errors.
  • Reviewed against the original report from the downstream MCP host (Codex CLI) where bare-401 originally tripped the transport-fault path. The same envelope-shaped response is what fixed the symptom downstream.
  • Verified by inspection that the success path, the Accept-header patch, CORS preflight, and the StreamableHTTPTransport handoff are byte-for-byte unchanged.

A wire-level diagnosis of why bare 4xx breaks strict MCP hosts (and why a JSON-RPC envelope is the right fix) was written up separately during the downstream investigation. Happy to share or adapt the relevant parts as a docs/ note if useful — let me know.

Files touched

  • server/index.ts — only file changed. +85 / -1.

…e HTTP 401)

Strict MCP hosts (Codex CLI, Claude Code) treat bare HTTP 4xx responses
as transport-level failures and tear down the connection rather than
recovering from an application-level auth error. Wrap the auth-failure
path in a JSON-RPC 2.0 error envelope (HTTP 200, code -32001) so the
protocol layer carries the failure and the connection stays alive.

The success path is unchanged. CORS preflight and StreamableHTTPTransport
behavior are preserved. Best-effort extraction of the inbound request id
keeps the response correlated with the call; malformed or missing bodies
fall back to id: null.
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.

1 participant