Summary
@spfn/mcp protects /mcp with a bearer token and answers 401 with a WWW-Authenticate: Bearer resource_metadata="…/.well-known/oauth-protected-resource" pointer, then hands the token to the app's validateToken. Nothing in @spfn/mcp or @spfn/auth serves what that pointer promises, so an app that wants Claude Code or Codex to connect from a user's machine has to build the entire OAuth authorization server itself. That is the same work for every SPFN app that exposes MCP, and the framework should own it.
What an MCP client actually does (observed)
Tested with Claude Code 2.1.x (claude mcp add --transport http) and Codex CLI 0.150 (mcp_servers.<name>.url), and confirmed against three public remote MCP servers (Linear, Notion, GitHub) by running the client side of the flow:
POST /mcp without a token → 401 with resource_metadata.
GET /.well-known/oauth-protected-resource[/<path>] → { resource, authorization_servers: [...] }.
GET <authorization server>/.well-known/oauth-authorization-server[/<issuer path>] → authorization_endpoint, token_endpoint, registration_endpoint, code_challenge_methods_supported: ["S256"].
POST <registration_endpoint> (dynamic client registration, RFC 7591) with redirect_uris: ["http://localhost:<random port>/callback"], token_endpoint_auth_method: "none". The CLI has no way to carry a pre-registered client id, so without DCR the client cannot connect at all. Servers that lack DCR (GitHub) require the operator to register an OAuth app by hand, which does not work for end users' local CLIs.
- Browser:
authorization_endpoint with PKCE S256, state, and the resource parameter (RFC 8707); the user logs in and approves; redirect to the localhost callback.
POST <token_endpoint> with code_verifier; later grant_type=refresh_token. Both public servers rotate the refresh token on refresh.
- Neither CLI does device-code login or accepts a manually typed token for a remote HTTP server in this flow, so
@spfn/auth's device-code login and ops tokens do not substitute.
What is missing
| Piece |
@spfn/mcp today |
@spfn/auth today |
401 challenge with resource_metadata |
yes |
|
/.well-known/oauth-protected-resource document |
no (pointer only) |
no |
/.well-known/oauth-authorization-server document |
no |
no |
| Dynamic client registration endpoint |
no |
no |
| Authorize endpoint + consent screen bound to the app's session |
no |
no (OAuth here is the client side: Google, Kakao, …) |
Token endpoint (code → tokens, refresh with rotation), PKCE S256, resource audience |
no |
no |
Token verification the app can hand to validateToken |
app-provided |
no |
Proposal
@spfn/auth: an authorization server module. Routes: the two well-known documents, POST /register (DCR; public clients, none auth method, redirect URIs restricted to http://localhost:* and http://127.0.0.1:* plus an app allow-list), GET /authorize (requires the SPFN session; renders or delegates a consent screen listing the client name, scopes and the resource), POST /token (authorization code with PKCE S256 required, refresh with rotation, audience = resource). Storage: OAuth clients, authorization codes, access/refresh tokens, through the package's own entities like the rest of @spfn/auth. A verifyAccessToken(token, resource) export that returns { clientId, scopes, expiresAt, userId }, i.e. exactly the shape createMcpRoute's validateToken wants.
@spfn/mcp: serve the protected-resource document itself from appUrl (resource, authorization_servers, scopes_supported, bearer_methods_supported: ["header"]), and document the two-line wiring: validateToken: verifyAccessToken from auth.
- Docs: one page "Let a user's Claude Code or Codex connect to your app", the flow above, and the two config snippets that result (
claude mcp add --transport http <name> <url>, Codex [mcp_servers.<name>] url = …).
Acceptance
- A fresh SPFN app with
@spfn/auth + @spfn/mcp and no custom OAuth code: claude mcp add --transport http app https://<app>/mcp followed by /mcp in Claude Code completes login in the browser and lists the app's tools; the same with Codex codex mcp add app --url https://<app>/mcp.
- Refresh works after the access token expires without a new browser round trip; the rotated refresh token is persisted.
- A token minted for one
resource is rejected on another.
- DCR refuses non-loopback
http redirect URIs and anything not on the allow-list.
Notes from the client-side test that shape the design
- Metadata discovery must handle the path-aware well-known form (
/.well-known/oauth-authorization-server/<issuer path>); GitHub only answers there. The server side should publish the root form and, if the issuer has a path, the path form too.
- Some hosts (Notion behind Cloudflare) 403 a bare
Python-urllib User-Agent; not our concern as a server, but worth a line in the docs for apps that also act as clients.
- Access-token lifetimes in the wild: 8 h (GitHub), 24 h (Linear). Refresh tokens rotate on every refresh at both, and GitHub's refresh token has its own expiry. Defaults in the same range are reasonable.
Summary
@spfn/mcpprotects/mcpwith a bearer token and answers 401 with aWWW-Authenticate: Bearer resource_metadata="…/.well-known/oauth-protected-resource"pointer, then hands the token to the app'svalidateToken. Nothing in@spfn/mcpor@spfn/authserves what that pointer promises, so an app that wants Claude Code or Codex to connect from a user's machine has to build the entire OAuth authorization server itself. That is the same work for every SPFN app that exposes MCP, and the framework should own it.What an MCP client actually does (observed)
Tested with Claude Code 2.1.x (
claude mcp add --transport http) and Codex CLI 0.150 (mcp_servers.<name>.url), and confirmed against three public remote MCP servers (Linear, Notion, GitHub) by running the client side of the flow:POST /mcpwithout a token → 401 withresource_metadata.GET /.well-known/oauth-protected-resource[/<path>]→{ resource, authorization_servers: [...] }.GET <authorization server>/.well-known/oauth-authorization-server[/<issuer path>]→authorization_endpoint,token_endpoint,registration_endpoint,code_challenge_methods_supported: ["S256"].POST <registration_endpoint>(dynamic client registration, RFC 7591) withredirect_uris: ["http://localhost:<random port>/callback"],token_endpoint_auth_method: "none". The CLI has no way to carry a pre-registered client id, so without DCR the client cannot connect at all. Servers that lack DCR (GitHub) require the operator to register an OAuth app by hand, which does not work for end users' local CLIs.authorization_endpointwith PKCE S256,state, and theresourceparameter (RFC 8707); the user logs in and approves; redirect to the localhost callback.POST <token_endpoint>withcode_verifier; latergrant_type=refresh_token. Both public servers rotate the refresh token on refresh.@spfn/auth's device-code login and ops tokens do not substitute.What is missing
@spfn/mcptoday@spfn/authtodayresource_metadata/.well-known/oauth-protected-resourcedocument/.well-known/oauth-authorization-serverdocumentresourceaudiencevalidateTokenProposal
@spfn/auth: an authorization server module. Routes: the two well-known documents,POST /register(DCR; public clients,noneauth method, redirect URIs restricted tohttp://localhost:*andhttp://127.0.0.1:*plus an app allow-list),GET /authorize(requires the SPFN session; renders or delegates a consent screen listing the client name, scopes and theresource),POST /token(authorization code with PKCE S256 required, refresh with rotation, audience =resource). Storage: OAuth clients, authorization codes, access/refresh tokens, through the package's own entities like the rest of@spfn/auth. AverifyAccessToken(token, resource)export that returns{ clientId, scopes, expiresAt, userId }, i.e. exactly the shapecreateMcpRoute'svalidateTokenwants.@spfn/mcp: serve the protected-resource document itself fromappUrl(resource,authorization_servers,scopes_supported,bearer_methods_supported: ["header"]), and document the two-line wiring:validateToken: verifyAccessTokenfrom auth.claude mcp add --transport http <name> <url>, Codex[mcp_servers.<name>] url = …).Acceptance
@spfn/auth+@spfn/mcpand no custom OAuth code:claude mcp add --transport http app https://<app>/mcpfollowed by/mcpin Claude Code completes login in the browser and lists the app's tools; the same with Codexcodex mcp add app --url https://<app>/mcp.resourceis rejected on another.httpredirect URIs and anything not on the allow-list.Notes from the client-side test that shape the design
/.well-known/oauth-authorization-server/<issuer path>); GitHub only answers there. The server side should publish the root form and, if the issuer has a path, the path form too.Python-urllibUser-Agent; not our concern as a server, but worth a line in the docs for apps that also act as clients.