How can I add OAuth to my MCP server?
Two discovery documents, dynamic client registration, PKCE with S256, and a 401 that points the way. Plus how to fix Cursor MCP OAuth errors.
You add OAuth to an MCP server by publishing two discovery documents, accepting dynamic client registration, running authorization code with PKCE and answering unauthenticated requests with a 401 that points at the first document. MCP clients (Claude Code, Cursor, Codex, ChatGPT, Windsurf) all walk the same chain, so once the chain is right, every client connects without a pre-shared secret.
This is the chain as Annsa implements it. It follows the MCP authorization spec, which in turn leans on 3 RFCs: 9728 for protected-resource metadata, 8414 for authorization-server metadata and 7591 for dynamic client registration.
Step 1: Answer the first request with a pointer
A client's first call arrives with no token. Do not return a bare 401. Return one whose WWW-Authenticate header says where to look:
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer realm="Annsa MCP",
resource_metadata="https://app.annsa.ai/.well-known/oauth-protected-resource/mcp"
Everything the client needs from here on is derived from that URL. It never has to be told anything else.
Step 2: Publish protected-resource metadata
At the URL you pointed to, serve RFC 9728 metadata: what the resource is, which authorization server protects it, how to send the token and what scopes exist.
{
"resource": "https://app.annsa.ai/mcp",
"authorization_servers": ["https://app.annsa.ai"],
"bearer_methods_supported": ["header"],
"scopes_supported": ["read", "write"]
}
Clients derive the path from the resource URL, so serve it at both /.well-known/oauth-protected-resource and /.well-known/oauth-protected-resource/mcp.
Step 3: Publish authorization-server metadata
At /.well-known/oauth-authorization-server on the origin you named, serve RFC 8414 metadata listing every endpoint:
{
"issuer": "https://app.annsa.ai",
"authorization_endpoint": "https://app.annsa.ai/mcp/auth",
"token_endpoint": "https://app.annsa.ai/mcp/token",
"registration_endpoint": "https://app.annsa.ai/mcp/register",
"revocation_endpoint": "https://app.annsa.ai/mcp/revoke",
"grant_types_supported": ["authorization_code", "refresh_token"],
"response_types_supported": ["code"],
"code_challenge_methods_supported": ["S256"],
"scopes_supported": ["read", "write"]
}
Step 4: Accept dynamic client registration
A coding agent on someone's laptop has no pre-issued client id. RFC 7591 lets it register itself: a POST to the registration endpoint with a name, redirect URIs and grant types, answered with a client id. Keep it open (no human approval, no secret) and use token_endpoint_auth_method: "none", because a public client cannot keep a secret anyway. Your security comes from PKCE, not from a client secret.
Step 5: Authorization code with PKCE, S256 only
The client sends the user to your authorization endpoint with a code_challenge. Require S256 and refuse plain. The user signs in and approves the scopes on your consent screen. You redirect back with a code; the client exchanges it with its verifier at the token endpoint and receives an access token and a refresh token.
Annsa's access tokens last 30 days and refresh tokens 90. Choose your own, but make refresh work: an agent that has to reopen a browser every day will be abandoned.
Step 6: Scope narrowly
Two scopes are enough for most servers. Annsa's are read (priorities, specs, survey results, search) and write (state changes: start building, mark shipped, share back). An agent that only answers questions should ask for read alone, and your consent screen should show the user exactly which it asked for.
Step 7: Keep protocol errors out of the auth path
This is the mistake that costs the most time. Once a client is authenticated, a call to a method you do not implement must come back as a JSON-RPC error, code -32601, inside a 200. If it comes back as a 401 (because the method fell through to an anonymous allow-list, say), every client reads it as an expired token and loops on re-authentication. We shipped that once. Auth errors are HTTP statuses; method errors are JSON-RPC bodies. Never mix them.
How to fix Cursor MCP OAuth authentication errors
When Cursor shows the server as failing to authenticate, it is almost always one of these, in this order:
- The
401has noresource_metadatapointer. Cursor cannot start discovery. Check the header on an unauthenticatedPOST. - Metadata is served on the wrong origin or path. The protected-resource document must be on the resource's origin; the authorization-server document must be on the issuer named inside it.
- PKCE method mismatch. The server refuses
S256or only acceptsplain. OnlyS256should be accepted. - Redirect URI not registered. Cursor registers its own callback at registration time; a server that ignores the registered URIs and checks a fixed one will refuse the code.
- A protocol error returned as
401. See step 7. The symptom is a server that authenticates, works for one call, then asks to authenticate again. - A stale token in the client. Re-authenticate from the client. In Cursor, remove and re-add the server; in Claude Code,
/mcp→ the server → Authenticate.
Annsa's own client-side notes for this are in common issues.
What Annsa does not implement, on purpose
Two things the auth.md convention allows are not offered: identity assertions from a third-party IdP and anonymous access. Annsa declares neither, so an agent never tries a method that would fail. Publishing what you do not support is part of adding OAuth well. The full agent-facing version of this document is at annsa.ai/auth.md.
Test the chain from outside
Run these against a fresh deployment:
- Unauthenticated
POST /mcp→401withresource_metadatainWWW-Authenticate GETthe protected-resource URL → names your authorization serverGETthe authorization-server URL → lists all endpoints andS256POSTregistration with no secret → a client id- Authorization with
code_challenge_method=plain→ refused - Valid token, unknown method →
200with a-32601body
If all six pass, Claude Code, Cursor and ChatGPT connect with the URL alone.