---
title: "How Remote MCP Servers Trigger OAuth Sign-In"
url: https://deallocatedobjects.com/posts/how-remote-mcp-servers-trigger-oauth-sign-in
author: Jake Marsh
published: 2026-11-09
updated: 2026-11-09
topics: ["AI & Agents", "Web"]
description: "When 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."
---

# How Remote MCP Servers Trigger OAuth Sign-In

By Jake Marsh, November 9, 2026. HTML: https://deallocatedobjects.com/posts/how-remote-mcp-servers-trigger-oauth-sign-in

When 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](https://modelcontextprotocol.io/specification/draft/basic/authorization) 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-Authenticate
```

The 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](https://datatracker.ietf.org/doc/html/rfc9728)) naming your authorization server:

```json
{
  "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](https://callforme.tel/?ref=deallocated-post) 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](https://taskhold.com/?ref=deallocated-post), trimmed down. Clients that sign in get challenged, and everything else can read the public parts:

```ts title="app/api/mcp/route.ts"
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](https://github.com/punkpeye/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:

```bash
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 -5
```

Run 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.
