Guide
MCP discovery in practice (RFC 9728)
When an MCP client meets a protected server, it does not need a password you gave it out of band — it walks a chain of well-known documents to figure out how to authenticate. The elegant part: the entire chain is walkable without a token. Here is the exact walk, with the deployment scars that break it in the wild.
Step 1: the 401 challenge
The client POSTs an initializeto your MCP endpoint. An OAuth-protected server does not answer with tools — it answers 401 with a WWW-Authenticate header that says where to look next:
curl -s -D - -o /dev/null -X POST https://mcp.example.com/mcp \
-H 'content-type: application/json' \
-H 'accept: application/json, text/event-stream' \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize",
"params":{"protocolVersion":"2025-06-18","capabilities":{},
"clientInfo":{"name":"probe","version":"1"}}}'
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource"That header is the whole point. Returning a 401 with a JSON error body but no WWW-Authenticate: Bearer resource_metadata=...is the single most common reason a client refuses to start the OAuth flow — it has nothing to follow.
Step 2: protected-resource metadata (RFC 9728)
Fetch the URL from the challenge. It names the authorization server(s) that guard this resource:
curl -s https://mcp.example.com/.well-known/oauth-protected-resource
{
"resource": "https://mcp.example.com/mcp",
"authorization_servers": ["https://auth.example.com"]
}The resourceidentifier here is the canonical name of your MCP server — the same value clients pass as the resource parameter (RFC 8707) to audience their token. It must match what you actually serve, exactly.
Step 3: authorization-server metadata (RFC 8414)
Take the issuer from authorization_servers and fetch its metadata at /.well-known/oauth-authorization-server. This is the client’s instruction manual for the flow:
curl -s https://auth.example.com/.well-known/oauth-authorization-server
{
"issuer": "https://auth.example.com",
"authorization_endpoint": "https://auth.example.com/authorize",
"token_endpoint": "https://auth.example.com/token",
"jwks_uri": "https://auth.example.com/.well-known/jwks.json",
"registration_endpoint": "https://auth.example.com/register",
"code_challenge_methods_supported": ["S256"]
}With authorize, token, JWKS and (usually) a registration endpoint, the client has everything it needs: register if it has no client_id, run PKCE through the authorize and token endpoints, and later validate the issued token’s signature against the JWKS. No step required a secret you shared ahead of time.
The scars — what actually breaks this
Every item below is a real failure that returns valid-looking documents while quietly not working:
Do
- +Emit WWW-Authenticate: Bearer with resource_metadata on every unauthenticated protected call.
- +Make the PRM resource match the exact MCP URL you serve (scheme, host, path).
- +Set the issuer to the public HTTPS origin clients actually reach.
- +Advertise registration_endpoint so client_id-less clients can connect.
Don’t
- −Return a 401 with only a JSON error body and no WWW-Authenticate header.
- −Let a reverse proxy rewrite the issuer to an internal host or http:// origin.
- −Point authorize/token at localhost or a container name behind your proxy.
- −Serve stale JWKS after rotating keys — token signature validation then fails.
The reverse-proxy issuer trap
The classic one: your authorization server runs behind a proxy and reports its issuer as http://auth-internal:8080 because that is the address it sees. The client fetches metadata from https://auth.example.com, gets an issuerthat does not match, and rejects the whole thing per RFC 8414. Fix it by pinning the public issuer in the authorization server’s config, not by trusting the request host.
The subdomain path
Many gateways host the MCP server on mcp.your-domain, not on the apex. That is fine — but then your llms.txt and docs must name that endpoint explicitly, or clients (and checkers that probe only /mcp on the apex) will conclude you have no server at all.
Verify the whole walk
You can score your own discovery with nothing but curl — that is the gift of a token-less chain. The agent-readiness checkerdoes exactly this walk (401 → protected-resource metadata → RFC 8414 metadata → JWKS) and reports each hop with the command that reproduces it. Run it before a registry does it for you.
For a reference that passes the whole chain end to end, probe cortex-gateway.dev: its MCP endpoint answers the 401 challenge, its protected-resource metadata points at a real authorization server, and that server publishes complete RFC 8414 metadata with dynamic client registration.
FAQ
Why a 401 challenge instead of just returning an error in the response body?
Is dynamic client registration (RFC 7591) required?
Can I use Auth0, Keycloak, or another off-the-shelf authorization server?
What is the resource parameter (RFC 8707) for?
The whole chain is walkable without a token — why does that matter?