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?

Because MCP clients bootstrap the OAuth flow from the WWW-Authenticate header, not from your JSON. An in-band error ("not authorized") tells the client something is wrong but not how to fix it. A 401 with WWW-Authenticate: Bearer resource_metadata="..." is the machine-readable "here is where to start" that makes clients begin the flow automatically.

Is dynamic client registration (RFC 7591) required?

Not by the spec, but in practice many MCP clients rely on it: they have no pre-registered client_id for your server, so they register on the fly. If your authorization-server metadata omits registration_endpoint, those clients cannot connect. Advertise it unless you have a deliberate reason not to.

Can I use Auth0, Keycloak, or another off-the-shelf authorization server?

Yes. The MCP server is the protected resource; the authorization server can be anything that publishes RFC 8414 metadata and issues tokens your server can validate against its JWKS. The protected-resource metadata simply points authorization_servers at that issuer.

What is the resource parameter (RFC 8707) for?

It binds an access token to a specific resource (your MCP server) so a token minted for one service cannot be replayed against another. The client passes resource on the authorization and token requests; the authorization server audiences the token accordingly. It is what lets a gateway propagate identity to backends without a token becoming a skeleton key.

The whole chain is walkable without a token — why does that matter?

It means anyone (a registry, a client, a checker) can score your OAuth setup end to end before authenticating: 401 -> protected-resource metadata -> authorization-server metadata -> JWKS. You can verify your own discovery is correct with nothing but curl, which is exactly how third-party indexes will judge it.
Verify any of this on a real domain with the agent-readiness checker— every result line ships the curl that reproduces it. See the pattern shipped end-to-end in cortex-gateway.