MCP Quickstart
This guide takes you from nothing to a working MCP authorization handshake on your machine:
- Run QAuth — the OAuth 2.1 authorization server (AS).
- Run a guarded MCP resource server — the bundled
memory-mcpexample, protected by@qauth-labs/mcp-guard. - Obtain a valid, audience-bound access token and call the protected resource.
By the end you will have reproduced the flow from
ADR-007: an MCP client, given only a server
URL, discovers the authorization server, registers, runs authorization_code +
PKCE, and calls the resource with a token QAuth minted and mcp-guard validated.
┌──────────────┐ ┌───────────────────────┐ ┌─────────────────────┐ │ MCP client │ │ MCP resource server │ │ QAuth (AS) │ │ (Claude Code,│ │ memory-mcp + mcp-guard│ │ :3000 │ │ curl, …) │ │ :8088 │ │ │ └──────┬───────┘ └───────────┬───────────┘ └──────────┬──────────┘ │ GET /mcp/memory (no token)│ │ │ ──────────────────────────▶│ │ │ 401 WWW-Authenticate: │ │ │ Bearer resource_metadata= │ │ │ ◀──────────────────────────│ │ │ GET /.well-known/oauth-protected-resource │ │ ──────────────────────────▶│ │ │ { authorization_servers: ["http://localhost:3000"], … } │ │ ◀──────────────────────────│ │ │ discover AS, register, authorization_code + PKCE │ │ ───────────────────────────────────────────────────────-─▶│ │ access_token (aud = http://localhost:8088, scope mcp:read)│ │ ◀───────────────────────────────────────────────────────-─│ │ GET /mcp/memory Authorization: Bearer <token> │ │ ──────────────────────────▶│ (mcp-guard verifies sig, │ │ 200 ✅ │ iss, aud, scope) │ │ ◀──────────────────────────│ │Scope of the bundled example.
memory-mcpis a minimal Fastify server that demonstrates the authorization half — the 401 challenge, Protected Resource Metadata, and token validation. It exposes plain HTTP routes (/mcp/memory) rather than the full MCP JSON-RPC transport, so you verify it withcurl. To drive it from a real MCP client, wrap your own MCP server withmcp-guardthe same way (see Step 3); the OAuth handshake is identical.
Prerequisites
Section titled “Prerequisites”- Docker 23.0+ and Docker Compose 2.0+ (BuildKit on by default)
- OpenSSL (to generate the JWT signing key)
- Node.js 20+ with
pnpmandtsx(to run the example resource server) curlandjqfor the walkthrough
Step 1 — Run QAuth (the authorization server)
Section titled “Step 1 — Run QAuth (the authorization server)”QAuth signs tokens with EdDSA (Ed25519). Generate a key pair and point the stack at it.
git clone https://github.com/qauth-labs/qauth.gitcd qauth
# 1. Generate the EdDSA signing key pairopenssl genpkey -algorithm Ed25519 -out private.pemopenssl pkey -in private.pem -pubout -out public.pem
# 2. Create your .env from the examplecp .env.docker.example .envEdit .env and set the keys (include the BEGIN/END lines, wrapped in double
quotes) and the issuer:
DB_PASSWORD=change_me
JWT_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----...contents of private.pem...-----END PRIVATE KEY-----"
JWT_PUBLIC_KEY="-----BEGIN PUBLIC KEY-----...contents of public.pem...-----END PUBLIC KEY-----"
JWT_ISSUER=http://localhost:3000
# Let dynamically-registered MCP clients request MCP scopes.# The default allows only OIDC-core scopes (openid profile email offline_access),# so an MCP client's `mcp:read`/`mcp:write` request would otherwise be denied.DEFAULT_DYNAMIC_REGISTRATION_SCOPES=openid profile email offline_access mcp:read mcp:writeStart the stack (auth-server + PostgreSQL 18 + Redis 7 + migrations) and verify:
docker compose up -dcurl -s http://localhost:3000/health | jq{ "status": "ok", "services": { "database": "connected", "redis": "connected" } }Confirm the discovery document advertises the endpoints an MCP client needs:
curl -s http://localhost:3000/.well-known/oauth-authorization-server | jq{ "issuer": "http://localhost:3000", "authorization_endpoint": "http://localhost:3000/oauth/authorize", "token_endpoint": "http://localhost:3000/oauth/token", "registration_endpoint": "http://localhost:3000/oauth/register", "jwks_uri": "http://localhost:3000/.well-known/jwks.json", "code_challenge_methods_supported": ["S256"], "resource_indicators_supported": true, "authorization_response_iss_parameter_supported": true, // RFC 9207 `iss` always emitted "client_id_metadata_document_supported": true, // CIMD on by default}Interactive API docs (Swagger UI) are served at
http://localhost:3000/docs. See the Docker guide for development mode, CIMD settings, and production considerations.
Step 2 — Run a guarded MCP resource server
Section titled “Step 2 — Run a guarded MCP resource server”The repo ships a runnable example — a tiny in-memory store protected by
mcp-guard in local-JWT validation mode (it verifies tokens against QAuth’s
JWKS, no per-request call to the AS).
cd libs/fastify/plugins/mcp-guard
QAUTH_ISSUER=http://localhost:3000 \MCP_RESOURCE=http://localhost:8088 \npx tsx examples/memory-mcp/server.tsMCP_RESOURCE is this server’s canonical URL — the aud value every accepted
token must carry (RFC 8707). In a separate shell, confirm the two unauthenticated
behaviours an MCP client relies on:
# Protected Resource Metadata (RFC 9728) — points clients at the AS:curl -s http://localhost:8088/.well-known/oauth-protected-resource | jq# → { "resource": "http://localhost:8088",# "authorization_servers": ["http://localhost:3000"], … }
# A protected call with no token → 401 + the Bearer challenge:curl -i http://localhost:8088/mcp/memory# → HTTP/1.1 401 Unauthorized# WWW-Authenticate: Bearer scope="mcp:read", resource_metadata="http://localhost:8088/.well-known/oauth-protected-resource"This is the trigger that starts the whole handshake: a 401 with a pointer to the metadata document and the scopes the operation needs, so a client can authorize in one round instead of guessing.
Step 3 — Connect an MCP client
Section titled “Step 3 — Connect an MCP client”Option A — A real MCP client (e.g. Claude Code)
Section titled “Option A — A real MCP client (e.g. Claude Code)”Wrap your actual MCP server with mcp-guard exactly as the example does
(register the plugin, guard routes with app.requireBearer /
app.requireScopes(...)), then add it to your MCP client as an HTTP server:
# Exact syntax varies by client/version — check `claude mcp add --help`.claude mcp add --transport http memory http://localhost:8088/mcpOn first use the client will:
- call the server, get the 401 +
WWW-Authenticatechallenge, - fetch Protected Resource Metadata and discover QAuth,
- obtain a
client_id— via Client ID Metadata Documents (CIMD), or via Dynamic Client Registration if it does not support CIMD (see below), - run
authorization_code+ PKCE, opening a browser for login + consent at QAuth (you’ll register/sign in and approve themcp:read/mcp:writescopes), - retry the call with the issued token —
mcp-guardvalidates it and returns200.
Because the client passes the resource URL as the RFC 8707 resource parameter,
the token’s aud is bound to http://localhost:8088 and is useless at any other
resource server.
Option B — Verify the handshake with curl (no browser)
Section titled “Option B — Verify the handshake with curl (no browser)”To prove the token half end-to-end without an interactive client, mint a
client_credentials (machine) token. This needs a client whose scopes and
audience are set explicitly, which the seed script provisions.
# The seed script targets an existing realm. The default realm ("master") is# created lazily on first auth request, so warm it up with one throwaway# Dynamic Client Registration call (open mode — no token required):curl -s -X POST http://localhost:3000/oauth/register \ -H 'Content-Type: application/json' \ -d '{"client_name":"warmup","grant_types":["authorization_code"],"redirect_uris":["http://localhost/cb"],"token_endpoint_auth_method":"none"}' \ >/dev/null
# Provision a machine client bound to the example's resource URL:cat > /tmp/mcp-demo-clients.json <<'JSON'{ "realm": "master", "clients": [ { "client_id": "memory-mcp-demo", "name": "memory-mcp demo (client_credentials)", "grant_types": ["client_credentials"], "scopes": ["mcp:read", "mcp:write"], "audience": ["http://localhost:8088"], "token_endpoint_auth_method": "client_secret_basic" } ]}JSON
# Seed it (prints the generated client_secret once — capture it):DATABASE_URL="postgresql://qauth:${DB_PASSWORD}@localhost:5432/qauth" \ pnpm nx run infra-db:db:seed-oauth-clients -- --manifest=/tmp/mcp-demo-clients.jsonExchange the credentials for an audience-bound token, then call the resource:
CLIENT_ID=memory-mcp-demoCLIENT_SECRET=<paste the secret printed above>
ACCESS_TOKEN=$(curl -s -X POST http://localhost:3000/oauth/token \ -u "$CLIENT_ID:$CLIENT_SECRET" \ -H 'Content-Type: application/x-www-form-urlencoded' \ -d 'grant_type=client_credentials' \ -d 'scope=mcp:read' \ -d 'resource=http://localhost:8088' | jq -r .access_token)
# Call the protected resource — mcp-guard verifies signature, issuer, aud, scope:curl -s http://localhost:8088/mcp/memory \ -H "Authorization: Bearer $ACCESS_TOKEN" | jq# → { "subject": "memory-mcp-demo", "client": "memory-mcp-demo", "items": {} } ✅A token carrying only mcp:read against the write route returns a step-up
challenge — exactly what drives incremental consent in a real client:
curl -i -X PUT http://localhost:8088/mcp/memory/greeting \ -H "Authorization: Bearer $ACCESS_TOKEN" \ -H 'Content-Type: application/json' -d '{"value":"hi"}'# → HTTP/1.1 403 Forbidden# WWW-Authenticate: Bearer error="insufficient_scope", scope="mcp:read mcp:write",# resource_metadata="http://localhost:8088/.well-known/oauth-protected-resource"(Request scope=mcp:read mcp:write at the token endpoint to get a token that
satisfies both routes.)
Client registration: CIMD vs DCR
Section titled “Client registration: CIMD vs DCR”QAuth supports two ways for an MCP client to obtain a client_id. Both are
advertised in discovery, but they are not co-equal: MCP Authorization
2026-07-28 says authorization servers and clients SHOULD support CIMD, and
deprecates RFC 7591 Dynamic Client Registration — retained only for
backwards compatibility with parties that do not support CIMD. QAuth keeps DCR
working (it is what makes the local walkthrough above practical), but prefer
CIMD for anything you deploy.
| Mechanism | When QAuth uses it | Best for |
|---|---|---|
| CIMD — Client ID Metadata Documents (MCP 2026-07-28, preferred) | client_id is an HTTPS URL resolving to a metadata document | Production clients with a stable, public metadata URL. No registration record is persisted (no open-DCR abuse surface). |
| DCR — Dynamic Client Registration (RFC 7591, deprecated by MCP 2026-07-28) | POST /oauth/register, open mode (no initial_access_token) | Local development and clients without a public URL. Scopes are capped to the realm allowlist (DEFAULT_DYNAMIC_REGISTRATION_SCOPES) — this is why Step 1 adds mcp:read mcp:write. |
- CIMD is on by default (
CIMD_ENABLED=true). It requires the client’sclient_idURL to be fetchable over HTTPS; the AS validates it (URL ==client_id, redirect-URI checks, SSRF guards, size/TTL limits). For purely local testing overhttp://localhost, CIMD is impractical — use DCR. MCP 2026-07-28 still citesdraft-ietf-oauth-client-id-metadata-document-00; the IETF draft has since reached -02 (6 July 2026), and QAuth’s implementation already satisfies its added hardening (HTTPS-only fetches with no redirect following, HTTP 200 required, and simple string comparison of the document’sclient_idagainst the requested one). - DCR open mode is rate-limited per IP (30 requests per 60-second window by
default —
REGISTER_CLIENT_RATE_LIMIT/REGISTER_CLIENT_RATE_WINDOW). Tighten or gate it for any internet-facing deployment. See ADR-007 §1 and the Docker guide CIMD section.
How tokens stay scoped to one resource
Section titled “How tokens stay scoped to one resource”The no-passthrough guarantee rests on audience binding (RFC 8707):
- The client sends
resource=<MCP server URL>on authorize/token requests. - QAuth mints the access token with
audset to that resource and refuses to widen it on refresh. mcp-guardrejects any token whoseauddoes not include its ownresource, and never forwards the inbound token upstream.
A token minted for resource A therefore cannot be replayed against resource B, even if both trust the same QAuth instance.
Troubleshooting
Section titled “Troubleshooting”| Symptom | Likely cause / fix |
|---|---|
401 even with a token | Token aud ≠ the server’s MCP_RESOURCE. Ensure the resource you requested matches MCP_RESOURCE exactly. |
invalid_scope at /oauth/token | The client’s scopes (machine client) or the realm allowlist (DCR client) doesn’t include the requested scope. Re-check Step 1’s DEFAULT_DYNAMIC_REGISTRATION_SCOPES or the seeded client’s scopes. |
| Seed script: realm not found | The master realm hasn’t been created yet — run the warm-up DCR call in Step 3 Option B first. |
mcp-guard can’t fetch JWKS | QAUTH_ISSUER must be reachable from the resource server and serve /.well-known/jwks.json. |
| Port already in use | Override PORT for the example, or remap 3000/5432/6379 in docker-compose.yml. |
Next steps
Section titled “Next steps”- OAuth 2.1 Flow — the
authorization_code+ PKCE flow in detail, with copy-pastecurlfor every step (build your own client). - Hosted UI — the login, consent, and resume screens an end user actually sees during that flow.
- Agent Authorization — when an AI agent acts on
behalf of a user: the
is_agentclient type, RFC 8693 token-exchange delegation, agent scope modes, and step-up before dangerous operations. @qauth-labs/mcp-guardREADME — full configuration,introspectionmode, and the framework-agnostic core.- ADR-007: MCP-First Positioning — why this is QAuth’s near-term product identity.
- Docker guide — development mode, CIMD configuration, production.