MCP endpoint: transport, metadata, bearer challenge, per-tool scopes #154

Open
opened 2026-09-15 18:24:30 +02:00 by lz · 0 comments
Owner

The resource-server half: /api/mcp plus 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/server v2.0.0 is Fetch-native, which is the decisive property: WebStandardStreamableHTTPServerTransport.handleRequest(req: Request) returns a Response, so /api/mcp is an ordinary SvelteKit +server.ts and stays inside hooks.server.ts. The Node IncomingMessage transport would have put the authenticated API surface on the raw Node handler — a third thing bypassing the auth gate, materially worse than the /oauth mount because that one decides nothing authenticated.

Concern Supplied by
initialize, tools/list, tools/call, ping, version negotiation McpServer + WebStandardStreamableHTTPServerTransport
401 + WWW-Authenticate: Bearer resource_metadata=… requireBearerAuth — returns the ready-to-return challenge Response
RFC 9728 protected resource metadata, path-aware oauthMetadataResponse
RFC 8414 authorization server metadata the same helper
Token verification interface OAuthTokenVerifier / verifyBearerToken
DNS rebinding protection validateHostHeader, originValidationResponse

Transport 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

tokenMay keys on event.route.id, and /api/mcp is a single route id. The existing model structurally cannot express "may list workspaces, may not delete one". So:

  • the gate decides only may this credential speak MCP at all;
  • registry.ts declares a required scope per tool, and tools/call enforces it.

TOKEN_ROUTES is not widened to reach this. Its GET-only rule is load-bearing: /api/workers/[id]/sessions POST has no masterKey guard of its own, and the method rule is the only thing between workspaces:read and creating a session. MCP is POST-based, so routing it through tokenMay would dismantle that. A test asserts /api/mcp is absent from the map.

Two scopes: nexus:read, nexus:write. The endpoint must never set locals.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.ts implements the SDK's OAuthTokenVerifier rather than a bespoke interface, so requireBearerAuth consumes it directly. Two implementations — LocalVerifier (own store) and JwksVerifier (external issuer, via jose) — selected by the same setting that drives the advertised issuer.

Both check iss, exp and aud bound to the Nexus resource per RFC 8707. Expiry is a read-time filter with the comparison negated — !(expires_at > now) — so a NaN clock fails closed, same construction and reasoning as the API token store.

Watch for

The metadata routes are +server.ts endpoints, so layout load functions 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/anything on a.lck.sh returns 303 → /unlock today.

The 401 is mandatory — Claude does not honour WWW-Authenticate on a 200. 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/mcp as a custom connector, completes authorization, lists tools, and calls list_workspaces.

The resource-server half: `/api/mcp` plus 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/server` v2.0.0 is **Fetch-native**, which is the decisive property: `WebStandardStreamableHTTPServerTransport.handleRequest(req: Request)` returns a `Response`, so `/api/mcp` is an ordinary SvelteKit `+server.ts` and stays **inside `hooks.server.ts`**. The Node `IncomingMessage` transport would have put the authenticated API surface on the raw Node handler — a third thing bypassing the auth gate, materially worse than the `/oauth` mount because that one decides nothing authenticated. | Concern | Supplied by | |---|---| | `initialize`, `tools/list`, `tools/call`, `ping`, version negotiation | `McpServer` + `WebStandardStreamableHTTPServerTransport` | | `401` + `WWW-Authenticate: Bearer resource_metadata=…` | `requireBearerAuth` — returns the ready-to-return challenge `Response` | | RFC 9728 protected resource metadata, **path-aware** | `oauthMetadataResponse` | | RFC 8414 authorization server metadata | the same helper | | Token verification interface | `OAuthTokenVerifier` / `verifyBearerToken` | | **DNS rebinding protection** | `validateHostHeader`, `originValidationResponse` | Transport 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 `tokenMay` keys on `event.route.id`, and `/api/mcp` is a **single** route id. The existing model structurally cannot express "may list workspaces, may not delete one". So: - the gate decides only *may this credential speak MCP at all*; - `registry.ts` declares a required scope per tool, and `tools/call` enforces it. **`TOKEN_ROUTES` is not widened to reach this.** Its GET-only rule is load-bearing: `/api/workers/[id]/sessions` POST has no `masterKey` guard of its own, and the method rule is the only thing between `workspaces:read` and creating a session. MCP is POST-based, so routing it through `tokenMay` would dismantle that. A test asserts `/api/mcp` is absent from the map. Two scopes: `nexus:read`, `nexus:write`. The endpoint must **never** set `locals.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.ts` implements the SDK's `OAuthTokenVerifier` rather than a bespoke interface, so `requireBearerAuth` consumes it directly. Two implementations — `LocalVerifier` (own store) and `JwksVerifier` (external issuer, via `jose`) — selected by the same setting that drives the advertised issuer. Both check `iss`, `exp` and `aud` bound to the Nexus resource per RFC 8707. Expiry is a read-time filter with the comparison negated — `!(expires_at > now)` — so a `NaN` clock fails closed, same construction and reasoning as the API token store. ## Watch for The metadata routes are `+server.ts` endpoints, so layout `load` functions 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/anything` on `a.lck.sh` returns `303 → /unlock` today. The `401` is mandatory — Claude does not honour `WWW-Authenticate` on a `200`. 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/mcp` as a custom connector, completes authorization, lists tools, and calls `list_workspaces`.
lz added this to the MCP support (#140) milestone 2026-09-15 18:24:30 +02:00
Sign in to join this conversation.
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
lz/agent-nexus#154
No description provided.