ADR 0015 — OAuth 2.1 resource-server toolkit for MCP
- Status: Accepted
- Date: 2026-05-29
Context
A remote MCP server is connectable from Claude (Desktop / web custom connector)
only through the MCP authorization spec (2025-06-18): OAuth 2.1 with PKCE, plus
Protected Resource Metadata (RFC 9728), Authorization Server Metadata
(RFC 8414), Dynamic Client Registration (RFC 7591) and Resource Indicators
(RFC 8707). A Bearer-only server returns a bare 401 with no
WWW-Authenticate; the client cannot discover where to authenticate, so the
connector never establishes.
createMcpHandler already owns the MCP transport and emits that 401. Without
OAuth support every consuming app must hand-roll the same discovery docs, DCR
endpoint and PKCE token flow — security-critical machinery, duplicated and
prone to drift. This is transport-level authentication, not domain logic, so it
belongs in the framework (the same reasoning as ADR 0007: tools from one
contract).
Decision
stitchkit ships the OAuth protocol mechanics as generic primitives; the consuming app supplies only identity and persistence.
Framework (generic, no domain types):
createMcpHandler({ protectedResource })emitsWWW-Authenticate: Bearer resource_metadata="…"on401(RFC 9728 §5.1).oauthProtectedResourceRoute()serves/.well-known/oauth-protected-resource.mountOAuthProvider()returns the authorization-server routes: AS metadata,/register(DCR),/authorize(PKCE) and/token(code + refresh).signJwt()(mirror of the existingverifyJwt) andverifyPkce()/ S256.
Application (domain):
- Pluggable
clients/codes/refreshTokensstores. - An
authorizeUser(req, authRequest)callback that authenticates the user and captures consent — returns{ userId }, or aResponseto drive the browser through the app’s own login first.
Token format
Access tokens are signed HS256 JWTs whose aud is the canonical resource
(MCP URL). Validation is the existing verifyJwt({ audience }) — no token
store, audience binding for free (RFC 8707). Refresh tokens are opaque, stored,
and rotated single-use (OAuth 2.1 §4.3.1 for public clients).
Consequences
- Any stitchkit MCP server becomes a native Claude connector by mounting four
routes and one config field — no
mcp-remotebridge, no hand-rolled OAuth. - The framework stays generic (ADR 0002): it never learns what a “user” is; the app owns login, consent and storage.
- No new runtime dependency — everything is built on
crypto.subtle. - The AS and RS may co-locate (one server) or split; the toolkit supports both
via the
authorizationServerslist.