# ADR 0015 — OAuth 2.1 resource-server toolkit for MCP

> Source: https://github.com/max-listov/stitchkit/blob/master/docs/decisions/0015-oauth-resource-server.md

---

# 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 })` emits
  `WWW-Authenticate: Bearer resource_metadata="…"` on `401` (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 existing `verifyJwt`) and `verifyPkce()` / S256.

Application (domain):

- Pluggable `clients` / `codes` / `refreshTokens` stores.
- An `authorizeUser(req, authRequest)` callback that authenticates the user and
  captures consent — returns `{ userId }`, or a `Response` to 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-remote` bridge, 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 `authorizationServers` list.
