MCP endpoint: transport, metadata, bearer challenge, per-tool scopes #154
Labels
No labels
bug
duplicate
enhancement
help wanted
invalid
question
wontfix
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
lz/agent-nexus#154
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
The resource-server half:
/api/mcpplus the discovery documents. This is the shell every tool plugs into — land it with the two cheapest read tools (list_workspaces,list_sessions) so it is demonstrably working, and add the rest in #155–#158.Depends on #148 (zod v4) and #151.
Use the SDK
@modelcontextprotocol/serverv2.0.0 is Fetch-native, which is the decisive property:WebStandardStreamableHTTPServerTransport.handleRequest(req: Request)returns aResponse, so/api/mcpis an ordinary SvelteKit+server.tsand stays insidehooks.server.ts. The NodeIncomingMessagetransport would have put the authenticated API surface on the raw Node handler — a third thing bypassing the auth gate, materially worse than the/oauthmount because that one decides nothing authenticated.initialize,tools/list,tools/call,ping, version negotiationMcpServer+WebStandardStreamableHTTPServerTransport401+WWW-Authenticate: Bearer resource_metadata=…requireBearerAuth— returns the ready-to-return challengeResponseoauthMetadataResponseOAuthTokenVerifier/verifyBearerTokenvalidateHostHeader,originValidationResponseTransport runs in stateless mode (
sessionIdGenerator: undefined). Session state, server-initiated requests, standalone streams and event replay are what stateful mode is for, and a tools-only server uses none of them.Protocol surface, version negotiation and error codes are the SDK's on purpose: revisions move — 2026-07-28 deprecated DCR — and a hand-rolled handshake does not track that.
Authorization is per tool, not per route
tokenMaykeys onevent.route.id, and/api/mcpis a single route id. The existing model structurally cannot express "may list workspaces, may not delete one". So:registry.tsdeclares a required scope per tool, andtools/callenforces it.TOKEN_ROUTESis not widened to reach this. Its GET-only rule is load-bearing:/api/workers/[id]/sessionsPOST has nomasterKeyguard of its own, and the method rule is the only thing betweenworkspaces:readand creating a session. MCP is POST-based, so routing it throughtokenMaywould dismantle that. A test asserts/api/mcpis absent from the map.Two scopes:
nexus:read,nexus:write. The endpoint must never setlocals.masterKey.Host and Origin
Validated on every request via the SDK's helpers. Not optional on a public endpoint — without it a browser on any origin can be made to drive the MCP server through DNS rebinding.
The seam
lib/server/auth/verify.tsimplements the SDK'sOAuthTokenVerifierrather than a bespoke interface, sorequireBearerAuthconsumes it directly. Two implementations —LocalVerifier(own store) andJwksVerifier(external issuer, viajose) — selected by the same setting that drives the advertised issuer.Both check
iss,expandaudbound to the Nexus resource per RFC 8707. Expiry is a read-time filter with the comparison negated —!(expires_at > now)— so aNaNclock fails closed, same construction and reasoning as the API token store.Watch for
The metadata routes are
+server.tsendpoints, so layoutloadfunctions do not run and the lock-screen bounce does not apply — and the auth gate passes them through because they are not under/api/. Both are load-bearing and incidental, so each gets a test. If either changes, Claude's discovery receives an HTML login page. The negative control is current live behaviour:/.well-known/anythingona.lck.shreturns303 → /unlocktoday.The
401is mandatory — Claude does not honourWWW-Authenticateon a200. A tool-level error in its place breaks discovery. Test both status and header; it is the one thing a wrapper could plausibly swallow.Done when
Claude connects to
https://<public_url>/api/mcpas a custom connector, completes authorization, lists tools, and callslist_workspaces.