How Remote MCP Servers Trigger OAuth Sign-In
Jake MarshWhen someone pastes your MCP server’s URL into Claude or ChatGPT, a sign-in window should open. What makes that happen is one HTTP response from your server. I run a few remote MCP servers with OAuth, and the response those clients need is the same one that gets a server marked as down in directories.
The Response That Starts It#
The flow in the MCP authorization spec begins when the client’s request gets a 401 with a WWW-Authenticate header pointing at your protected resource metadata:
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer resource_metadata="https://example.com/.well-known/oauth-protected-resource/mcp"
Access-Control-Expose-Headers: WWW-AuthenticateThe expose header is there for clients running in a browser. Without it, their JavaScript can’t read the challenge.
That URL returns a small JSON document (RFC 9728) naming your authorization server:
{
"resource": "https://example.com/mcp",
"authorization_servers": ["https://example.com"],
"bearer_methods_supported": ["header"],
"scopes_supported": ["mcp"]
}From there the client reads your authorization server’s metadata, registers itself and opens the browser. I serve the metadata at both /.well-known/oauth-protected-resource/mcp and the bare /.well-known/oauth-protected-resource, because some clients try the root first.
Two Callers Want Opposite Things#
If an anonymous initialize returns 200, an OAuth-capable client can decide your server needs no auth. It connects, lists the tools and only fails later on tools/call, with no sign-in window. So those clients need the 401 on their very first request.
Directories and health checkers want the opposite. They call initialize and tools/list with no credentials, and many of them read a 401 as an outage. CallForMe logged about 700 of those 401s from checkers in two days, and one directory listed it as “Unhealthy, 0.0% uptime over 32 days” while it was running fine.
Answer By Who’s Asking#
So the endpoint looks at the caller. This is the version in Taskhold, trimmed down. Clients that sign in get challenged, and everything else can read the public parts:
const ANONYMOUS_METHODS = new Set([
"initialize", "server/discover", "notifications/initialized",
"tools/list", "ping", "prompts/list", "resources/list",
]);
const OAUTH_CLIENT_UA = /\b(Claude-User|claude-ai|openai-mcp|ChatGPT|claude-code|Cursor|codex-mcp-client|Visual Studio Code|goose)\b/i;
const OAUTH_CLIENT_NAME = /\b(gemini|visual studio code|vscode|windsurf|zed)\b/i;
async function isDiscoveryOnly(request: Request): Promise<boolean> {
if (request.headers.get("Authorization")) return false;
if (OAUTH_CLIENT_UA.test(request.headers.get("User-Agent") ?? "")) return false;
try {
const body = await request.clone().json();
const msgs = Array.isArray(body) ? body : [body];
if (msgs.some((m) => m?.method === "initialize" && OAUTH_CLIENT_NAME.test(String(m?.params?.clientInfo?.name ?? "")))) return false;
return msgs.length > 0 && msgs.every((m) => typeof m?.method === "string" && ANONYMOUS_METHODS.has(m.method));
} catch {
return false;
}
}The User-Agent catches most clients. Some send something generic like node, and those name themselves in clientInfo inside the initialize body, which is why there’s a second regex.
A spoofed User-Agent gets a caller nothing here, since the list only decides who sees a challenge. Every tool that touches data still needs a token, and anonymous requests run against a backend that throws on any call.
Which Default To Pick#
Taskhold defaults to anonymous and challenges a list of known OAuth clients. CallForMe does the inverse: it defaults to the 401 and opens up for a list of known checkers. Your choice decides who gets the wrong answer when a caller you’ve never seen shows up:
- With a list of OAuth clients, a new client gets a 200 and may not offer sign-in until someone adds it.
- With a list of checkers, a new checker gets a 401 and may list you as down.
I’d pick by which mistake costs you more. CallForMe also matches checkers by how they describe themselves (mcp-?index, health-?check, crawler), so new ones don’t sit on a 401 until I notice them.
There are exceptions on both sides. The CI for awesome-remote-mcp-servers labels a server by its initialize reply: 200 means no auth, and a 401 with resource_metadata means OAuth. An OAuth server that wants the right label has to challenge that checker too, so it’s in Taskhold’s full list.
Log The 401s#
Some clients see the challenge and never finish. codex mcp add --url without codex mcp login hit a 401 on every session: in early November my logs showed 7 IPs stuck in one day and no completed Codex sign-ins in 3 days. CallForMe now lets unauthenticated Codex sessions into a public mode that needs no sign-in, and signed-in ones keep their token. Another runtime registered with the OAuth server, never called /authorize and kept retrying hourly.
I only found these by logging each 401 with its User-Agent, limited to once per IP and client every 6 hours. Comparing that against completed sign-ins shows which clients get stuck.
You can check your own server’s answer with curl by changing the User-Agent:
curl -si -X POST https://example.com/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-H 'User-Agent: claude-code/2.0' \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"test","version":"1"}}}' \
| head -5Run it once with that header and once with User-Agent: directory-check. The first should return the 401 and the second a 200, unless your server is one that challenges everyone by default.