Zuplo
MCP error

Resource server does not implement OAuth 2.0 Protected Resource Metadata.

Your server answers 401 correctly, but the response carries no pointer to protected resource metadata — so the client has nothing to discover the authorization server with, and the OAuth flow never starts.

MCP Specification
2026-07-28

Is this you?

Two requests, no credentials. Read the raw challenge line, then fetch the document it should point at. This separates "the pointer is missing" from "the pointer is there and something later broke".

TerminalRun this to check — it changes nothing
# 1. The challenge. Read the WWW-Authenticate line verbatim.
curl -sS -D - -o /dev/null -X POST https://your-server.example.com/mcp \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json, text/event-stream' \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'

# 2. The document a client probes when the header omits it.
curl -isS https://your-server.example.com/.well-known/oauth-protected-resource/mcp
This error
A 401 with no WWW-Authenticate at all, or one that stops at Bearer realm="…" or Bearer error="invalid_token" with no resource_metadata, while the second request 404s. If the well-known request answers 401 instead of 200, something in front of the server is intercepting the discovery path too. A 200 whose resource is not the URL you dialed is the same failure one step later — RFC 9728 requires the client to discard that document, and both reference SDKs reject any resource that is not the same origin and a path prefix of the URL they requested.
Then the fix is the next section.
Working
HTTP/1.1 401 carrying WWW-Authenticate: Bearer error="invalid_token", error_description="Authentication required", scope="mcp:read", resource_metadata="https://your-server.example.com/.well-known/oauth-protected-resource/mcp", and a 200 application/json from the second request whose resource is exactly https://your-server.example.com/mcp — the URL a client dials — and whose authorization_servers names at least one server. A 401 with no header is also fine as long as that document is served: a conformant client falls back to the well-known probe.
Then this is not your problem — try the error reference.
4 other strings clients print for this
  • Could not fetch resource metadata
  • No authorization_servers found in resource metadata <url> - Is this resource metadata configured correctly?
  • Protected resource <advertised> does not match expected <dialed>
  • Failed to start OAuth flow for <server>: SDK auth failed: Protected resource <advertised> does not match expected <dialed> (or origin)

Dotted spans such as <url> are placeholders — your client prints your own value there.

Fix it

Make the 401 carry resource_metadata="…" and serve the document it points at. In the MCP Python SDK one setting does both — resource_server_url on AuthSettings; the TypeScript SDK needs two, resourceMetadataUrl on requireBearerAuth for the challenge and mcpAuthMetadataRouter to mount the document; FastMCP needs the verifier wrapped in RemoteAuthProvider. Publish the external URL clients actually dial, host and path included, as that document's resource — RFC 9728 requires a client to discard metadata whose resource does not match. If your code already sets the header, something in front of the server is replacing the 401 on the way out, and that is the layer to fix.

Have your agent fix this

Copies a prompt that sends your coding agent to this page, then has it find and apply the right fix for your project.

The cause depends on your platform. Pick yours.

MCP Python SDK

If your server is the MCP Python SDK and you passed token_verifier= with an AuthSettings whose resource_server_url is None

  1. 1 resource_server_url is a required field on AuthSettings, but it accepts None, and nothing validates that you supplied a URL. RequireAuthMiddleware appends resource_metadata="…" to the challenge only when its resource_metadata_url argument is set, and the server only passes that argument when settings.auth.resource_server_url is present. Pass None and the 401 goes out as WWW-Authenticate: Bearer error="invalid_token", error_description="Authentication required" — right status, no pointer.
  2. 2 The same setting mounts the document. create_protected_resource_routes is registered only when resource_server_url is set, so the client's well-known fallback 404s as well and discovery has nowhere left to go.
  3. 3 Set resource_server_url to the public URL of the MCP endpoint itself, not the origin. build_resource_metadata_url inserts the well-known segment between host and path, so https://api.example.com/mcp publishes at https://api.example.com/.well-known/oauth-protected-resource/mcp.
  4. 4 That value also becomes resource in the document, and the client checks it against the URL it dialed — same origin, and a path prefix of that URL. Behind a TLS-terminating proxy this has to be the external https:// URL, or you trade this error for the mismatch variant.
python
# mcp 2.x, which is a release candidate at the time of writing. On the
# 1.x stable line the class is FastMCP; the AuthSettings fields below are
# the same.
from mcp.server import MCPServer
from mcp.server.auth.settings import AuthSettings
from pydantic import AnyHttpUrl

mcp = MCPServer(
    "Notes",
    token_verifier=MyTokenVerifier(),
    auth=AuthSettings(
        issuer_url=AnyHttpUrl("https://auth.example.com"),
        # Omit this, or pass None: 401 with no resource_metadata, and no
        # well-known route mounted.
        resource_server_url=AnyHttpUrl("https://api.example.com/mcp"),
        required_scopes=["notes:read"],
    ),
)
MCP Python SDK: Authorization

MCP TypeScript SDK

If your server gates the route with requireBearerAuth and omits resourceMetadataUrl

  1. 1 resourceMetadataUrl is optional in BearerAuthOptions. The SDK's challenge builder adds error and error_description unconditionally, scope when requiredScopes is non-empty, and resource_metadata only when that URL is set.
  2. 2 Build the URL with getOAuthProtectedResourceMetadataUrl(new URL('https://api.example.com/mcp')) and pass it to requireBearerAuth. The Express middleware and the web-standard fetch gate share one core, so this is the same fix on Workers, Deno, Bun and Hono.
  3. 3 Serve what the challenge points at: mcpAuthMetadataRouter({ oauthMetadata, resourceServerUrl }) mounts /.well-known/oauth-protected-resource/mcp plus a mirror of the authorization server metadata. On a fetch-handler host use oauthMetadataResponse from @modelcontextprotocol/server.
  4. 4 The same challenge is emitted on 403 insufficient_scope, so setting resourceMetadataUrl once also fixes scope step-up.
TypeScriptts
// @modelcontextprotocol/express and /server are the 2.x split packages,
// in beta at the time of writing. On stable @modelcontextprotocol/sdk
// (1.x) the same three exports live under the /server/auth path.
import {
  getOAuthProtectedResourceMetadataUrl,
  mcpAuthMetadataRouter,
  requireBearerAuth
} from '@modelcontextprotocol/express';

const mcpServerUrl = new URL('https://api.example.com/mcp');

const auth = requireBearerAuth({
  verifier,
  requiredScopes: ['mcp'],
  // Omit this and the 401 challenge carries no resource_metadata.
  resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(mcpServerUrl)
});

app.use(mcpAuthMetadataRouter({ oauthMetadata, resourceServerUrl: mcpServerUrl }));
app.all('/mcp', auth, (req, res) => void node(req, res, req.body));
MCP TypeScript SDK: Require authorization

FastMCP

If you pass a bare verifier as FastMCP's auth=JWTVerifier, IntrospectionTokenVerifier, StaticTokenVerifier, DebugTokenVerifier

  1. 1 This is documented behaviour, not a bug. FastMCP's token-verification docs: "TokenVerifier focuses exclusively on token validation without providing OAuth discovery metadata" and "Token verification operates somewhat outside the formal MCP authentication flow, which expects OAuth-style discovery."
  2. 2 Wrap the verifier in RemoteAuthProvider with authorization_servers and base_url. That is the piece that adds discovery: "The core discovery endpoint is /.well-known/oauth-protected-resource, which tells clients that your server requires OAuth authentication and identifies the authorization servers you trust."
  3. 3 Keep the bare verifier only where every client is provisioned with a token out of band — internal services, a fixed set of agents. It works; it just cannot bootstrap a client that has never seen your server, which is what you are hitting.
  4. 4 base_url is what the published resource is derived from, so it has to be the externally reachable URL of the server, not a container-local one.
python
from fastmcp import FastMCP
from fastmcp.server.auth import RemoteAuthProvider
from fastmcp.server.auth.providers.jwt import JWTVerifier
from pydantic import AnyHttpUrl

token_verifier = JWTVerifier(
    jwks_uri="https://auth.example.com/.well-known/jwks.json",
    issuer="https://auth.example.com",
    audience="mcp-production-api",
)

# auth=token_verifier alone publishes no discovery metadata.
auth = RemoteAuthProvider(
    token_verifier=token_verifier,
    authorization_servers=[AnyHttpUrl("https://auth.example.com")],
    base_url="https://api.example.com",
)

mcp = FastMCP(name="Protected API", auth=auth)
FastMCP: Remote OAuth

AWS API Gateway (REST)

If a Lambda or Cognito authorizer on a REST API answers the 401 before your integration runs

  1. 1 API Gateway writes that response itself: "If API Gateway fails to process an incoming request, it returns to the client an error response without forwarding the request to the integration backend." Your MCP server never executes, so no header it would have set exists.
  2. 2 The response type is UNAUTHORIZED, default status 401 — "The gateway response when the custom or Amazon Cognito authorizer failed to authenticate the caller." Customize it and map a literal WWW-Authenticate value, single-quoted as parameter mappings require.
  3. 3 ACCESS_DENIED (403) is the sibling used for authorization failure. Set it too if you emit insufficient_scope challenges.
  4. 4 Expose the metadata document on a route with no authorizer attached. If /.well-known/oauth-protected-resource/... is behind the same authorizer, the client's fallback probe gets a 401 and discovery still fails.
  5. 5 Gateway response customization is REST-API only — the AWS reference for responseParameters says "Supported only for REST APIs." On an HTTP API you have to emit the challenge from a proxy integration or from a component in front of API Gateway.
JSONjson
"x-amazon-apigateway-gateway-responses": {
  "UNAUTHORIZED": {
    "statusCode": "401",
    "responseParameters": {
      "gatewayresponse.header.WWW-Authenticate": "'Bearer resource_metadata=\"https://api.example.com/.well-known/oauth-protected-resource/mcp\", scope=\"mcp\"'"
    },
    "responseTemplates": {
      "application/json": "{\"error\":\"invalid_token\"}"
    }
  }
}
API Gateway: gateway response types

ingress-nginx

If ingress-nginx fronts the server and 401 is listed in custom-http-errors

  1. 1 Listing a status code there turns on interception — "Setting at least one code also enables proxy_intercept_errors which are required to process error_page." The client then receives the default backend's response, and "NGINX does not change the response from the custom default backend" — your challenge is gone with the rest of your 401.
  2. 2 Remove 401 (and 403, if you use scope challenges) from the ConfigMap custom-http-errors, or override it on the MCP ingress with the nginx.ingress.kubernetes.io/custom-http-errors annotation, which "will set NGINX proxy-intercept-errors, but only for the NGINX location associated with this ingress." The annotation takes a comma-separated list of codes and its value replaces the global list for that host and path, so list the codes you do want intercepted and leave 401 out. An empty annotation value is not a way to say "none": the parser rejects it as invalid content and the global list still applies.
  3. 3 Check hide-headers in the same ConfigMap too — it drops named headers from the upstream response before the client sees them.
  4. 4 If you must keep custom error pages, the default backend has to emit the challenge itself. NGINX hands it X-Code and X-Original-URI, and "the custom backend is expected to return the correct HTTP status code instead of 200."
YAMLyaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: mcp
  annotations:
    # Replaces the global custom-http-errors list for this host and path.
    # 401 and 403 are absent, so they are not intercepted here and the
    # WWW-Authenticate header reaches the client intact.
    nginx.ingress.kubernetes.io/custom-http-errors: "404,503"
ingress-nginx: custom errors

Azure Container Apps

If Container Apps built-in authentication (Easy Auth) is returning the 401 for you

  1. 1 Container Apps Easy Auth with unauthenticatedClientAction: Return401 answers www-authenticate: Bearer realm="<app>.<region>.azurecontainerapps.io" and nothing else — no resource_metadata. It also answers /.well-known/oauth-protected-resource itself with 401, so the client's fallback probe never reaches your container and both discovery paths are closed.
  2. 2 There is no Container Apps setting that fixes this today. A Microsoft collaborator has confirmed the gap on an open issue in the Container Apps repo; the same issue reports that WEBSITE_AUTH_PRM_DEFAULT_WITH_SCOPES, the App Service setting that does it, appears to be silently ignored on the Container Apps sidecar, and asks Microsoft to confirm whether it is meant to work there at all.
  3. 3 App Service and Functions have the documented counterpart: set the WEBSITE_AUTH_PRM_DEFAULT_WITH_SCOPES app setting to a comma-separated list of your application's scopes and App Service Authentication hosts the protected resource metadata. Microsoft labels PRM support preview.
  4. 4 On Container Apps, exclude the MCP route and /.well-known/oauth-protected-resource from Easy Auth and emit the challenge and the document from your own code, or move the server to App Service.
Terminalbash
# What Container Apps Easy Auth returns on the discovery path today:
curl -isS https://<app>.<region>.azurecontainerapps.io/.well-known/oauth-protected-resource

# HTTP/2 401
# www-authenticate: Bearer realm="<app>.<region>.azurecontainerapps.io"
#
# No resource_metadata in the challenge, and the document itself is gated,
# so neither discovery mechanism can succeed while Easy Auth owns the route.
microsoft/azure-container-apps#1736

Something else

Whatever component you are running, the fix has one of two shapes. Either make the 401 carry resource_metadata="…" pointing at a document you actually serve, or serve /.well-known/oauth-protected-resource/<path> and let the client's fallback probe find it — the MCP authorization spec (revision 2025-11-25) accepts either mechanism and requires clients to support both. Then find the last hop that touched the response: run the curl above against the server directly, then again through each proxy, ingress or platform gate in front of it, and change the layer where the header disappeared rather than the MCP handler. Whichever component publishes the document, its resource value has to be the external URL clients dial — host and path — or RFC 9728 requires them to discard it.

Why it happens

A client that gets a 401 does not guess where to authenticate. It reads resource_metadata out of the WWW-Authenticate challenge, fetches that RFC 9728 document, and follows authorization_servers to the authorization server; with no pointer it falls back to probing /.well-known/oauth-protected-resource/<path> and then the root, and if neither is served, discovery is over — the client reports a metadata or connection error rather than prompting anyone to log in. The pointer is optional in both reference SDKs, and omitting it drops the parameter from the challenge with no warning. The other case is a header your code did set: something in front of the server replaces the 401 before it reaches the client.

The exchange

Each step in the exchange, in order.

  • MCP client
  • MCP server
  • Authorization server
  1. MCP client to MCP server

    POST /mcp no Authorization header
  2. The challenge is a valid 401 and machine-readable, but it carries no resource_metadata parameter, so the client has no metadata URL to follow.

    MCP server to MCP client

    401 Unauthorized Bearer error="invalid_token"
  3. With nothing in the header, a conformant client probes the path-based well-known URI and then the root. Neither is served, so discovery ends here and the authorization server is never named.

    MCP client to MCP server

    GET well-known fallback /.well-known/oauth-protected-resource/mcp
  4. MCP server to MCP client

    404 Not Found no metadata document mounted

    Flow stops here — Authorization server not reached

The MCP client posts to the server unauthenticated and gets a 401 whose WWW-Authenticate challenge carries no resource_metadata parameter. The client falls back to probing the well-known protected resource metadata path, that returns 404, and discovery ends there — the authorization server is never contacted.
Zuplo

What lands on you next

The challenge header is one line of one standard, and it took a day to find. There are five more standards in this flow, and then the questions stop being about connecting at all.

Which other discovery documents does a client expect from us?

RFC 9728 protected resource metadata

The challenge points at a metadata document, which points at authorization server metadata, which the client then uses to register and to request scopes. A gateway serves that chain as one unit.

The client authenticated. Which of our tools may it call?

mcp-capability-filter-inbound

An authenticated client is not an authorized one. The filter allow-lists capabilities per route and blocks the rest with MethodNotFound before the request reaches your server.

Which caller is this, next time a request looks wrong?

capability_invocation

Each tool call resolves to a caller, a capability and an outcome with a named reason code, rather than to a status line in an access log.

Can this token be replayed against our other MCP route?

RFC 8707 resource indicators

No. A gateway-issued token is bound to one route's canonical resource URI, so a token minted for one route is rejected at another.

All of these attach to one MCP route's policies.inbound — the same policy engine on the way in and on the way out.

Stop debugging auth on every MCP server

A gateway in front of your MCP servers handles discovery, audience binding, and token validation once — for every server and every client.