MCP OAuth Authorization — Design
Status: proposed — 2026-08-23. Spec: MCP authorization (draft), built on OAuth 2.1, RFC 9728 (protected resource metadata), RFC 8414 (authorization server metadata), RFC 8707 (resource indicators), RFC 9207 (issuer identification), and OAuth Client ID Metadata Documents.
Problem
Connecting a remote MCP client to /mcp today requires a person to mint an
ntk_ token in Settings → MCP → Connect an agent remotely and paste it
into the client’s config (docs/mcp-production.md). That
works, but:
- Every spec-conforming MCP client (Claude Desktop, Claude Code, VS Code,
ChatGPT connectors) already speaks the OAuth discovery flow: hit the server,
get a 401, discover the authorization server, open a browser, done. NodeTool
answers that first unauthenticated request with a bare 401 — no
WWW-Authenticateheader (packages/websocket/src/lib/ws-upgrade.ts) — so the automatic flow dead-ends and the user is back to pasting tokens. - An
ntk_token is a full-API bearer for its minter. A credential that exists only so a chat client can call MCP tools should not also authorize every REST route, tRPC procedure, and asset download. - Pasted tokens have no client identity: revocation is “which of these five tokens named ‘claude’ is the one I still use?”.
Decision summary
- NodeTool is both the resource server and the authorization server. The
MCP spec lets the AS be a separate entity, but there is no external AS to
point at in either auth mode: Local mode has no IdP at all, and Supabase
GoTrue does not serve arbitrary third-party OAuth clients (no RFC 8414
metadata for them, no resource indicators, no CIMD). So the websocket server
grows a minimal OAuth 2.1 AS, issuer =
NODETOOL_PUBLIC_URL. User authentication inside the flow reuses whatever the install already has: the web session (Supabase mode) or the trusted-local rules (Local mode). - Authorization code + PKCE (S256) only. Public clients, no client
secrets,
token_endpoint_auth_methods_supported: ["none"]. No client-credentials or implicit grants. - Client registration: Client ID Metadata Documents first, RFC 7591 dynamic
registration as the compatibility fallback. CIMD needs no registration
table for the client itself — the
client_idis an HTTPS URL the AS fetches and validates. DCR stays because shipped clients still use it. - Opaque tokens, DB-verified, audience-bound to
/mcp. Same hash-and-compare scheme asntk_(packages/models/src/access-token.ts), new prefixes so the auth hook can route verification:nta_(access, short-lived) andntr_(refresh, rotating). Annta_token authorizes only/mcp; every other route rejects it.ntk_tokens keep working everywhere, unchanged. - One scope:
mcp. The MCP tool surface is already permission-gated per-call inside the agent-tools layer; inventing granular OAuth scopes now would duplicate that gate with a second, coarser one. The scope string exists so challenges and step-up have somewhere to grow.
Roles and endpoints
MCP client NodeTool server (one Fastify app, NODETOOL_PUBLIC_URL)
---------- -------------------------------------------------------
Resource server:
POST/GET/DELETE /mcp (exists)
GET /.well-known/oauth-protected-resource/mcp (new)
GET /.well-known/oauth-protected-resource (new)
Authorization server:
GET /.well-known/oauth-authorization-server (new)
GET /oauth/authorize (new)
POST /oauth/token (new)
POST /oauth/register (new, RFC 7591)
POST /oauth/revoke (new, RFC 7009)
All five new prefixes are auth-exempt (lib/public-routes.ts) — none reads
per-caller state before authenticating on its own terms — and excluded from the
SPA fallback (server.ts), or /.well-known/* gets served index.html.
The flow
sequenceDiagram
participant B as Browser
participant C as MCP client
participant N as NodeTool (RS + AS)
C->>N: POST /mcp (no token)
N->>C: 401, WWW-Authenticate: Bearer resource_metadata="…/.well-known/oauth-protected-resource/mcp", scope="mcp"
C->>N: GET /.well-known/oauth-protected-resource/mcp
N->>C: { resource: "…/mcp", authorization_servers: ["…"], scopes_supported: ["mcp"] }
C->>N: GET /.well-known/oauth-authorization-server
N->>C: AS metadata (PKCE S256, CIMD supported, registration_endpoint, iss supported)
alt CIMD
Note over C: client_id = https URL of its metadata document
else DCR
C->>N: POST /oauth/register
N->>C: { client_id }
end
C->>B: open /oauth/authorize?client_id&redirect_uri&code_challenge&resource&scope&state
B->>N: GET /oauth/authorize
Note over N: authenticate user (web session / trusted local), fetch+validate CIMD, render consent
B->>N: approve (authenticated tRPC call)
N->>B: 302 redirect_uri?code&state&iss
B->>C: authorization code
C->>N: POST /oauth/token (code, code_verifier, resource, redirect_uri, client_id)
N->>C: { access_token: "nta_…", refresh_token: "ntr_…", expires_in: 3600, scope: "mcp" }
C->>N: POST /mcp, Authorization: Bearer nta_…
N->>C: MCP response
401 challenge
denyUnauthorized (packages/websocket/src/lib/ws-upgrade.ts) grows an
optional challenge parameter. The /mcp path — and only it, for now — answers:
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer resource_metadata="<PUBLIC_URL>/.well-known/oauth-protected-resource/mcp", scope="mcp"
An expired or invalid token gets the same header plus
error="invalid_token". Insufficient scope (future, once more scopes exist)
gets 403 with error="insufficient_scope" and the scopes the operation needs.
scopeRefusal() in mcp-server.ts keeps its JSON-RPC -32001 body but the
HTTP layer adds the header.
The challenge is emitted only when the flow can complete: mcpHttpEnabled and
configuredMcpUrl() non-null (lib/mcp-mount.ts already owns both). A server
with no NODETOOL_PUBLIC_URL cannot name its own resource URI and answers the
plain 401 it does today.
Protected resource metadata (RFC 9728)
Served at both well-known forms the spec’s fallback order probes —
/.well-known/oauth-protected-resource/mcp (path-aware, matching the /mcp
endpoint path) and the root document:
{
"resource": "<PUBLIC_URL>/mcp",
"authorization_servers": ["<PUBLIC_URL>"],
"scopes_supported": ["mcp"],
"bearer_methods_supported": ["header"],
"resource_name": "NodeTool MCP"
}
resource is the canonical URI (lowercase scheme/host, no trailing slash),
derived from configuredMcpUrl() — not a fourth copy of the public-URL logic.
Authorization server metadata (RFC 8414)
/.well-known/oauth-authorization-server:
{
"issuer": "<PUBLIC_URL>",
"authorization_endpoint": "<PUBLIC_URL>/oauth/authorize",
"token_endpoint": "<PUBLIC_URL>/oauth/token",
"registration_endpoint": "<PUBLIC_URL>/oauth/register",
"revocation_endpoint": "<PUBLIC_URL>/oauth/revoke",
"response_types_supported": ["code"],
"grant_types_supported": ["authorization_code", "refresh_token"],
"code_challenge_methods_supported": ["S256"],
"token_endpoint_auth_methods_supported": ["none"],
"scopes_supported": ["mcp"],
"client_id_metadata_document_supported": true,
"authorization_response_iss_parameter_supported": true
}
code_challenge_methods_supported is load-bearing: spec-conforming clients
refuse to proceed when it is absent. iss is emitted on every
authorization response (success and error) per RFC 9207, so the flag is true.
Client registration
Client ID Metadata Documents (primary)
A URL-shaped client_id (https, with a path) is fetched by the AS at
authorize time and validated: the document’s client_id must equal the URL
exactly, the structure must carry client_id, client_name, redirect_uris,
and the request’s redirect_uri must exactly match one of them. The fetch goes
through safeFetch from @nodetool-ai/runtime — the SSRF guard the spec’s
security considerations call for already exists and is mandatory repo policy
for any URL somebody else chose
(docs/url-egress-inventory.md gets a new row).
Responses are cached respecting HTTP cache headers, capped (size ≤ 64 KB,
timeout 5 s), and never trusted across the client_id mismatch.
Dynamic registration (fallback)
POST /oauth/register accepts {client_name, redirect_uris, grant_types,
token_endpoint_auth_method: "none", application_type} and answers a generated
client_id (ntc_<random>), no secret. Rows live in a new mcp_oauth_clients
table. Registration is open (that is the point of DCR) but rate-limited via the
existing fastifyRateLimit registration, and rows are garbage-collected when
they age out with no grant ever issued (30 days).
Redirect URIs at registration and at authorize time must be https://… or
loopback (http://127.0.0.1[:port]/…, http://localhost[:port]/…); anything
else is rejected.
Authorize and consent
GET /oauth/authorize validates the request (client, redirect_uri, S256
code_challenge, resource equal to the canonical /mcp URI when present,
scope ⊆ {mcp}), then hands off to the web UI: a redirect to
/oauth/consent?request_id=… inside the SPA, with the pending request parked
server-side (same in-memory TTL-swept store pattern as oauthStateStore in
packages/websocket/src/oauth-api.ts, 10-minute TTL).
The consent page runs inside the normal web app, so user authentication is
whatever the install already enforces: Supabase session in cloud mode, the
trusted-local rules in Local mode (where the auth hook resolves userId: "1"
on loopback). Unauthenticated users go through the existing login and return.
The page shows the client’s client_name, the redirect URI host (spec
requirement — with an explicit warning when every redirect URI is
loopback-only, since CIMD cannot prevent localhost impersonation), and the
scope. Approve calls a new authenticated tRPC procedure
(agentAccess.approveOauthRequest) that mints the code and returns the
redirect URL; the SPA navigates there. Deny redirects with
error=access_denied. Both redirects carry state (echoed verbatim) and
iss.
Errors that arrive before a redirect_uri is validated (unknown client, bad redirect_uri) render in-page and never redirect — open-redirect rule.
Token endpoint
POST /oauth/token, application/x-www-form-urlencoded:
grant_type=authorization_code— verify: code exists, unexpired (≤ 10 min), unused;client_idmatches;redirect_uriexact-matches the authorize request;code_verifierS256-hashes to the storedcode_challenge(constant-time compare —PKCEHelperinpackages/runtime/src/providers/oauth/pkce-helper.tsalready implements S256);resource, when sent, equals the one bound at authorize time. A reused code revokes every token already issued from it (OAuth 2.1 code replay rule), not just fails.grant_type=refresh_token— rotate: verify thentr_token, issue a new access + refresh pair, invalidate the old refresh token. A rotated-out refresh token presented again is reuse-detection: revoke the whole grant.
Response: access_token (nta_…, expires_in: 3600), token_type:
"Bearer", refresh_token (ntr_…, absolute lifetime 30 days), scope.
Token model and storage
Three new tables (Drizzle, SQLite + PG mirrors, next to
schema/access-tokens.ts):
mcp_oauth_clients— DCR rows:id (ntc_…),client_name,redirect_uris (json),created_at,last_used_at. CIMD clients are not rows; their identity is the URL.mcp_oauth_grants— one per (user, client) consent:id,user_id,client_id(URL orntc_…),client_name(denormalized for the UI),scope,resource,created_at,revoked_at. The revocation unit the settings UI operates on.mcp_oauth_tokens—id,grant_id,kind: access | refresh,secret_hash(SHA-256,timingSafeEqualcompare — theAccessTokenscheme reused verbatim),expires_at,rotated_from(refresh lineage for reuse detection),last_used_at.
Authorization codes are in-memory only (TTL store): a code lives ≤ 10 minutes and the loss on restart costs one browser round-trip. Nothing durable, nothing to migrate.
Audience enforcement is structural, not claim-parsing: the auth hook in
server.ts routes on prefix. nta_ verifies against mcp_oauth_tokens and
sets req.userId only when req.url is /mcp; on any other path it is
denyUnauthorized. The stored resource is additionally compared to
configuredMcpUrl() so a token minted when the server lived at one public URL
does not survive a rehost. This is the RFC 8707 audience-validation MUST,
implemented without JWTs. Conversely /mcp keeps accepting ntk_, delegated,
and Supabase tokens — the OAuth path is additive; token passthrough is still
impossible because /mcp mints nothing and forwards nothing.
What each auth mode gets
| Local (default) | Supabase / cloud (Fly) | |
|---|---|---|
| Flow enabled | when NODETOOL_PUBLIC_URL is set (else unchanged: loopback trust means most local clients never see a 401) |
when NODETOOL_ENABLE_MCP=1 and NODETOOL_PUBLIC_URL set — same gate as the mount |
| User auth at consent | trusted-local (userId: "1") |
Supabase web session |
| Issuer scheme | http://localhost tolerated (spec permits loopback redirect URIs; a non-TLS non-loopback issuer disables the flow per request: no challenge, AS routes 404) |
HTTPS required |
No new enable flag: the ability to answer the challenge is derived from
configuration that already exists. NODETOOL_DISABLE_MCP_OAUTH=1 is the
escape hatch for an operator who wants token-paste only.
Security checklist (spec MUSTs → where enforced)
- PKCE S256 mandatory,
plainrejected — token endpoint. - Exact redirect_uri match, registered values only — authorize + token.
- Single-use codes; reuse revokes issued tokens — token endpoint.
- Refresh rotation + reuse detection for public clients — token endpoint.
isson all authorization responses — authorize.- Audience binding:
nta_valid only for/mcp+ storedresourcecheck — auth hook. - No token passthrough:
/mcpnever forwards inbound bearers upstream — unchanged invariant. - CIMD fetch through
safeFetch(SSRF), size/time caps,client_idequality — authorize. - Consent shows client name + redirect host, warns on loopback-only URIs — consent page.
- AS endpoints HTTPS in production; loopback exception local — enforced per request by the shared gate (
oauth/gate.ts). - Secrets stored hashed; raw token appears once in the token response — token model.
- Rate limits on
/oauth/tokenand/oauth/register— existingfastifyRateLimit.
Out of scope, deliberately
- Granular scopes / step-up. One
mcpscope until a real consumer needs a narrower one; the challenge plumbing (403 +insufficient_scope) is where it would land. - NodeTool as AS for anything but
/mcp. The REST/tRPC surface keeps its existing token types. - JWT access tokens / introspection endpoint. One process is both AS and RS; a DB lookup is simpler and revocation is immediate.
- The
.mcpbbundle and stdio transport. Stdio explicitly stays on environment credentials per spec; the bundle’s bearer-token user config keeps working.
Implementation phases
- Resource-server half — metadata routes,
WWW-Authenticatechallenge,nta_verification in the auth hook, tables. Independently shippable: clients discover, fail at the missing AS, fall back to token paste. - Authorization-server half — authorize/consent/token/register/revoke, the tRPC procedure, the SPA consent page.
- Settings surface —
agent-access.tsrouter +AgentAccessSection.tsx: list connected clients (grant rows), revoke, and reportauth_mode: "oauth"inmcpConnection. docs/mcp-production.md rewritten around “connect via browser” with token paste demoted to fallback.
Tests: route-level Vitest in packages/websocket (challenge shape, metadata
documents, full code+PKCE happy path, every rejection in the checklist above —
each proven able to fail per the repo’s check rules), model tests beside
access-token.test.ts, and a probe that the SPA fallback does not swallow
/.well-known/*.