Blog, by , , 9 min read

Remote MCP server returns 401? How MCP OAuth works

What a 401 from a remote MCP server means under the 2026-07-28 spec, how the client finds the login, why registration fails, and how to test it with curl.

9min read
On this page
  1. Step 1: the 401 and its header
  2. Step 2: the protected resource metadata
  3. Step 3: the authorization server metadata
  4. Step 4: getting a client ID
  5. Step 5: the authorization code, with PKCE and resource
  6. Step 6: the token, and the 403 that isn't a 401
  7. Checklist
  8. Frequently asked questions

Short answer: a 401 from a remote MCP server is not an outage. It is the first step of the login flow the MCP authorization spec defines: the server is up and wants a bearer token. The WWW-Authenticate header on that response points at a protected-resource metadata document (or the client looks for it at /.well-known/oauth-protected-resource) that names the authorization server; the client fetches that server's metadata, obtains a client ID, runs an OAuth 2.1 authorization-code flow with PKCE and a resource parameter, and retries with Authorization: Bearer <token>. When a client "can't connect", the failure is in one of those steps, and each one can be checked with a single curl. This post walks through them, as of 25 October 2026.

Some background: the protocol says authorization is optional and, for HTTP transports, "SHOULD conform to this specification". The MCP server plays the role of an OAuth 2.1 resource server, the MCP client is an OAuth 2.1 client, and an authorization server, which may be the same host or a separate identity provider, issues the tokens. The 2026-07-28 revision removed the initialize handshake, so the first request you send is already a real one, such as tools/list; Remote MCP server not connecting? Test it by hand covers that request and its headers. ToolVet runs an MCP handshake against every listed server on a schedule and counts a server that asks for a login as reachable, because it answered (how we check and score). So ToolVet can tell you a server is up, but not whether your login to it will work; for that, read on.

Step 1: the 401 and its header

Send the unauthenticated request and look at the headers:

curl -si https://mcp.example.com/mcp \
  -H 'content-type: application/json' \
  -H 'accept: application/json, text/event-stream' \
  -H 'mcp-protocol-version: 2026-07-28' \
  -H 'mcp-method: tools/list' \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{"_meta":{
        "io.modelcontextprotocol/protocolVersion":"2026-07-28",
        "io.modelcontextprotocol/clientInfo":{"name":"curl","version":"1.0"},
        "io.modelcontextprotocol/clientCapabilities":{}}}}'

A conforming server answers something like:

HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource",
                         scope="files:read"

The spec's discovery rules give the server two ways to point at its metadata, and it must use at least one: the resource_metadata parameter in WWW-Authenticate, or a well-known URI. Clients "MUST support both discovery mechanisms and use the resource metadata URL from the parsed WWW-Authenticate headers when present; otherwise, they MUST fall back to constructing and requesting the well-known URIs". The fallback order matters: first the path-aware form, /.well-known/oauth-protected-resource/mcp for a server at /mcp, then the root /.well-known/oauth-protected-resource.

The scope parameter, which servers SHOULD include, is the server's statement of what this operation needs. Clients "MUST treat the scopes provided in the challenge as authoritative for the current operation", and when it is absent they fall back to the scopes_supported list in the protected resource metadata document.

What goes wrong here: a bare 401 with no WWW-Authenticate header and no well-known document leaves a spec-following client with nothing to do but abort or ask the user for pre-configured values. A 401 whose body is a login page is the same problem. If you run the server, add the header; it is one line.

Step 2: the protected resource metadata

curl -s https://mcp.example.com/.well-known/oauth-protected-resource

This is the RFC 9728 document the spec requires every protected MCP server to publish. It "MUST include the authorization_servers field containing at least one authorization server", and it carries a resource field, the server's canonical URI. When several authorization servers are listed, the client picks; its registrations and tokens are kept per authorization server and never reused across them.

What goes wrong here: the document is behind the same login it is meant to unlock, returns HTML, or lists an issuer that doesn't match what the authorization server will later call itself (step 3 rejects that).

Step 3: the authorization server metadata

Take the issuer from authorization_servers and fetch its metadata. For an issuer without a path, https://auth.example.com, the client "MUST try" in order:

  1. https://auth.example.com/.well-known/oauth-authorization-server (RFC 8414)
  2. https://auth.example.com/.well-known/openid-configuration (OpenID Connect Discovery)

For an issuer with a path, https://auth.example.com/tenant1, the order is the RFC 8414 path-inserted form, then OIDC with path insertion, then OIDC with the path appended. A client must support both discovery standards; the authorization server needs to offer only one. Then comes a check that is easy to skip: the issuer value in the document "MUST be identical to the issuer identifier used to construct the well-known URL. If they differ, the client MUST NOT use the metadata." The spec's own example is a document fetched from an attacker's host that claims "issuer": "https://honest.example".

The metadata tells the client what it needs next: the endpoints, whether client_id_metadata_document_supported is true, whether a registration_endpoint exists, and authorization_response_iss_parameter_supported.

Step 4: getting a client ID

This is where "it worked with one client and not another" comes from. The client registration page defines three mechanisms and a priority order a client should follow:

  1. Pre-registered credentials, if the client has them for this server.
  2. Client ID Metadata Documents (CIMD), if the authorization server advertises client_id_metadata_document_supported. The client's ID is an HTTPS URL, such as https://app.example.com/oauth/client-metadata.json, pointing at a JSON document with at least client_id, client_name and redirect_uris; the authorization server fetches it, checks that the client_id inside matches the URL exactly, validates the redirect URI against the list, and shows the user a consent page with the client_name. The spec says clients and authorization servers "SHOULD support" CIMD, and it is the recommended path when the two have no prior relationship.
  3. Dynamic Client Registration (RFC 7591), if the metadata has a registration_endpoint. It is deprecated as of 2026-07-28, and "remains available for backwards compatibility with authorization servers that do not support Client ID Metadata Documents"; it is eligible for removal in the first revision released on or after 28 July 2027 per the deprecated features registry (what else 2026-07-28 deprecated).
  4. Ask the user to type a client ID and secret, when nothing else is available.

What goes wrong here: the authorization server supports neither CIMD nor dynamic registration, so a client with no pre-registered ID and no UI for entering one stops with a registration error. Or registration is rejected over the redirect URI: an OpenID Connect server may refuse loopback callbacks such as http://127.0.0.1:… because a missing application_type defaults to "web" under OIDC; the spec now requires clients to set it appropriately, "native" for desktop, CLI and localhost apps. Or the server's metadata switched to a different authorization server and the client keeps presenting credentials from the old one; the spec binds credentials to the issuer that gave them and says the client "MUST re-register with the new authorization server". CIMD identifiers are portable across authorization servers; dynamic registrations are not.

Step 5: the authorization code, with PKCE and resource

The client opens the browser on the authorization endpoint with a PKCE challenge, the scopes from step 1, and a resource parameter that "MUST identify the MCP server that the client intends to use the token with", in canonical form: https://mcp.example.com/mcp, no fragment, no trailing slash unless it means something. The same parameter goes on the token request. It is sent "regardless of whether authorization servers support it". Before the redirect, the client records the issuer from step 3; if the callback carries an iss parameter (RFC 9207), the client compares it to the recorded value by plain string comparison, no case folding, no normalisation, and rejects the response on mismatch. If the metadata said authorization_response_iss_parameter_supported: true and iss is missing, the client must reject the response too.

What goes wrong here: the user sees the consent page and the client still ends with an error. One cause is a resource value that isn't the one the resource server expects in the token's audience, so step 6 fails; another is a callback iss that differs from the issuer in the metadata (a proxy, a trailing slash, a different hostname for the same identity provider).

Step 6: the token, and the 403 that isn't a 401

The access token goes in the Authorization: Bearer header on every request; it must not go in the query string. The server must validate that the token was issued for it as audience and must answer 401 for an invalid or expired token. The spec's error table, in short:

Status Meaning
401 Authorization required, or the token is invalid or expired
403 Invalid scopes or insufficient permissions
400 Malformed authorization request

A 403 at runtime should come with WWW-Authenticate: Bearer error="insufficient_scope", scope="files:write", resource_metadata="…". The spec's step-up flow: take the union of the scopes you previously requested and the ones in the challenge, re-authorize, retry the original request "no more than a few times", then treat it as permanent. Servers "SHOULD include all scopes required for the current operation in a single challenge"; one missing scope per round trip means repeated consent screens.

Three rules in the token-handling section close the confused-deputy and token-passthrough cases, two for servers and one for clients. An MCP server "MUST only accept tokens that are valid for use with their own resources" and "MUST NOT accept or transit any other tokens": forwarding the client's token to a downstream API, or accepting a token minted for some other API, is the confused-deputy case. And a client "MUST NOT send tokens to the MCP server other than ones issued by the MCP server's authorization server", so an API key for another service pasted into the Authorization header is a client bug, however the server reacts to it.

Checklist

If you run a remote MCP server behind a login: return 401 with WWW-Authenticate: Bearer resource_metadata="…" and a scope; publish /.well-known/oauth-protected-resource with resource and authorization_servers; make sure your authorization server publishes metadata whose issuer matches its URL exactly and supports CIMD, because dynamic registration is on its way out; validate the token's audience against your canonical URI; answer missing scopes with 403 and insufficient_scope, every needed scope in one challenge.

If you build a client or an agent: parse WWW-Authenticate first, fall back to the two well-known paths in order, try both discovery documents, reject metadata whose issuer doesn't match, send resource on both requests, check iss on the callback, keep registrations and tokens per issuer, and treat 401 as "log in" and 403 as "ask for more scope", never as "the server is down". Whether the server is up is a question ToolVet already answers, on every listing in the directory and on the ToolVet MCP server.

Frequently asked questions

Does a 401 mean the remote MCP server is down?

No. Under the MCP authorization spec a 401 is how a protected server starts the OAuth flow: it is up, and the WWW-Authenticate header (or the /.well-known/oauth-protected-resource document) tells the client where to log in. ToolVet counts a server that asks for a login as reachable for the same reason.

Why does my MCP client say it can't register with the server?

Usually because the authorization server offers neither automatic registration mechanism: Client ID Metadata Documents (advertised by client_id_metadata_document_supported: true) or a registration_endpoint for dynamic registration. The remaining options are a pre-registered client ID or typing one in. The other common cause is a registration rejected over the redirect URI (see step 4). Dynamic registration is deprecated since 2026-07-28, so new servers should support CIMD.

What is a Client ID Metadata Document in MCP?

A JSON file the client hosts at an HTTPS URL, and that URL is the client_id. It must contain at least client_id, client_name and redirect_uris, with client_id equal to the URL. The authorization server fetches it on demand, validates the redirect URI against it and shows the client_name on the consent page, so no prior registration is needed.

What is the resource parameter for in MCP OAuth?

It is RFC 8707's resource indicator: the canonical URI of the MCP server the token is for, sent on both the authorization and the token request. The server then checks that the token's audience is itself, which stops a token issued for one server from being replayed at another.

Everything quoted above is from the MCP specification revision 2026-07-28 as read on 25 October 2026. How we check and score explains how ToolVet tests remote MCP servers and why a login request counts as a reachable server.