Skip to main content
Glama
dawa

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.