Skip to content

perf(auth): authorize each MCP call once via in-process dispatch - #385

Open
tnunamak wants to merge 1 commit into
mcp/b1-grant-id-selectionfrom
mcp/p1-inprocess-dispatch
Open

tnunamak wants to merge 1 commit into
mcp/b1-grant-id-selectionfrom
mcp/p1-inprocess-dispatch

Conversation

@tnunamak

Copy link
Copy Markdown
Contributor

Every POST /mcp tool call introspects the caller's token twice, or three times for an expanded read: once on the way in, and again when the hosted route calls the resource server (RS) over a loopback HTTP request carrying the same bearer. That second call is also the MCP authorization spec's forbidden pass-through: an MCP server must not forward the token it received. A cross-request cache would remove the cost but breaks revocation, so this change keeps one introspection per outer request and no cache.

For client (non-package) tokens, the hosted route now introspects once and runs each tool sub-call through the real /v1 Fastify route chain in process (fastify.inject), carrying the already-introspected context. The context lives in a per-request registry (server/internal-dispatch.ts) keyed by a 128-bit random nonce. The inner request sends that nonce as its bearer, never the user's token. The registry entry holds a clone of the context, is deleted when the request completes, throws or aborts, and is honored only for in-process calls (a real socket presenting the nonce goes through ordinary introspection and gets 401 invalid_token). Expiry is re-checked on each dispatch, and event token ids come from the cloned context's grant_id, so the nonce is never persisted. vendor/mcp-server's RsClient is unchanged. Package tokens keep their per-child path until the package binding lands.

Measured on real routes, 300 samples per arm after warmup, alternating order, p50 in milliseconds (HTTP self-call, one-per-request memo, this change): MCP schema 10.6, 9.5, 8.6 with 2, 1, 1 introspections; MCP query_records 9.6, 8.6, 8.1 with 2, 1, 1. A memo alone captures most of the gain; this change adds about 0.5 to 1 ms and removes the pass-through, and it is the base for serving package tokens through package endpoints. Scripts, raw samples and counts: p1-measure.md in the review notes.

Tests cover exactly one introspection per call (two before), revocation seen on the next request, no user bearer on any inner request, nonce hygiene (never in logs, events, responses, or after abort at the first event write), forged and replayed nonces, concurrent isolation, and cleanup on abort before authentication. 493 tests pass, with the type check. An independent review by a different model ran four rounds; its findings (abort during introspection, a reusable marker header, an unrestricted client capability, a persisted nonce) are fixed, and the capability design was replaced by the nonce bearer. Not verified: the per-mount audience check (/mcp versus /v1) is a separate change and its test is a marked todo; the vendor tool-footprint test still fails on its missing operator document.

This PR is stacked on #382 (and so #381); retarget it after they merge.

Assisted-by: AI

Resolved in-process requests carry a marked copy of the outer introspection context. Derive event token IDs from that context's grant ID, so deleting an aborted scope cannot make its nonce eligible for persistence. Ordinary requests keep their existing event identity.

Assisted-by: AI
Signed-off-by: Tim Nunamaker <tnunamak@gmail.com>
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