mcp_auth_server
by dawa
README.md
# mcp_auth_server
A local, runnable proof of concept of the [MCP Authorization spec (2025-11-25)](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization).
It demonstrates, end to end:
- an **MCP server** acting as a proper **OAuth 2.1 resource server** — publishes RFC 9728
Protected Resource Metadata, validates **audience-bound** access tokens, and enforces scope
on a protected tool;
- a **one-command MCP client** that discovers the authorization server, registers via
**Client ID Metadata Documents (CIMD)**, runs the full OAuth 2.1 + PKCE handshake (including
a **step-up** re-authorization), and calls the protected tool;
- a **minimal custom OAuth 2.1 authorization server** behind the MCP server.
## Why a custom authorization server?
CIMD is `draft-ietf-oauth-client-id-metadata-document-00`. No off-the-shelf OSS or SaaS
authorization server supports it yet, so using one would still require writing a CIMD shim.
A small custom AS gives full, correct CIMD support and lets the whole thing run locally as a
single command.
## Quick start
```bash
uv venv && uv pip install -e ".[dev]" # one-time setup
./scripts/run.sh # or: uv run python -m mcp_auth.demo
```
`run.sh` starts the authorization server and the resource server, then runs the client, which
prints a step-by-step trace of the handshake and the protected tool's result, e.g.:
```
[1] Discovery
401 challenge -> resource_metadata=...
PRM: resource=http://localhost:8001/mcp as=['http://localhost:9000/'] scopes=['mcp:connect']
AS metadata discovered at http://localhost:9000/.well-known/oauth-authorization-server
[2] Authorization (CIMD + PKCE) for base scope
[3] Call unprotected tool 'whoami'
[4] Call protected tool 'get_weather' (expect insufficient scope)
[5] Step-up re-authorization for the extra scope
[6] Retry 'get_weather' with stepped-up token
=== Success: full CIMD + OAuth 2.1 + step-up flow completed ===
```
## Architecture
```
src/mcp_auth/
config.py shared ports, URLs, scopes, token TTL
keys.py authorization-server RSA signing key + JWKS export
auth_server/ custom OAuth 2.1 authorization server (Starlette)
app.py AS metadata, /authorize, /token, /jwks
cimd.py fetch + validate Client ID Metadata Documents
store.py single-use authorization-code store
tokens.py RS256 JWT minting (aud=resource) + PKCE S256 verify
resource_server/ FastMCP server as an OAuth 2.1 resource server
server.py FastMCP app + whoami + protected get_weather tool
verifier.py JWT verifier: signature, issuer, expiry, audience
client/ one-command CIMD-driven MCP client
metadata.py the client's CIMD document + a server that hosts it
flow.py 401 -> PRM -> AS discovery -> PKCE authorize -> token
run.py full journey incl. step-up
demo.py orchestrator (single command)
```
Default ports: authorization server `:9000`, resource server `:8001`, client CIMD host
`:3000`. The canonical resource URI (the token **audience**) is `http://localhost:8001/mcp`.
### How the flow maps to the spec
| Spec requirement | Where |
| --- | --- |
| RFC 9728 Protected Resource Metadata | FastMCP, auto-served at `/.well-known/oauth-protected-resource/mcp` |
| `401` + `WWW-Authenticate` with `resource_metadata` | FastMCP `RequireAuthMiddleware` |
| RFC 8414 AS metadata incl. `code_challenge_methods_supported`, `client_id_metadata_document_supported` | `auth_server/app.py` |
| CIMD: fetch client doc, validate `client_id` == URL, validate `redirect_uri` | `auth_server/cimd.py` |
| Authorization code + PKCE `S256` | `auth_server/app.py`, `client/flow.py` |
| RFC 8707 `resource` parameter in auth + token requests | `client/flow.py` |
| Audience-bound tokens; resource server validates `aud` | `auth_server/tokens.py`, `resource_server/verifier.py` |
| Scope enforcement + `insufficient_scope` + step-up | `resource_server/server.py`, `client/run.py` |
## Tests
```bash
uv run pytest
```
Covers AS/PRM metadata shape, CIMD validation (mismatched `client_id`, unlisted redirect URI,
missing fields, SSRF host), PKCE and token claims, audience/expiry rejection in the verifier,
and a full single-command end-to-end run.
## Deliberate deviations from the spec (because this is a local PoC)
These are the conscious trade-offs that let the PoC run fully locally with one command. Each
would be tightened for a real deployment:
- **HTTP on `localhost`** instead of HTTPS. The spec mandates HTTPS for AS endpoints and the
CIMD `client_id` URL; localhost redirect URIs are already spec-permitted. The MCP SDK's
`AnyHttpUrl` accepts `http`, so no patching is needed.
- **Headless auto-approved consent.** The AS authenticates a fixed demo subject and issues a
code without a browser prompt, so the client reads the redirect directly (no callback
listener). A real AS would authenticate the user and show a consent screen.
- **In-memory, process-local state** (signing key, authorization codes). No persistence, no
refresh-token rotation.
- **SSRF guard is a localhost allowlist** for CIMD fetches, sufficient for local use; a real AS
needs a stricter trust policy per the CIMD draft's security considerations.
## Out of scope
- `infra/terraform/` (anticipated by `.gitignore`) — a local PoC needs no cloud infra.
- Multi-user login UI, Dynamic Client Registration (CIMD is used instead), persistent storage.
This server cannot be deployed
Maintenance
ActivityInactive
ResponsivenessNo issues