You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
401 and error for a request with no authorization header (for a request with no token) - as per spec no error should be added #3769
Title: Auth spec conformance (minor): 401 challenge contents, Environment:mcp 2.3.0 · Python 3.12.3 · spec revision 2026-07-28
A small difference from the spec text, only shows up in a corner case.
1. The 401 for a request with no token
RequireAuthMiddleware._send_auth_error sends error="invalid_token" both for a missing token and for an invalid one, and never adds scope, even when required_scopes is set.
RFC 6750 §3.1: when a request lacks any authentication, the resource server "SHOULD NOT include an error code or other error information". MCP Scope Selection Strategy: servers "SHOULD include a scope parameter in the WWW-Authenticate header". The spec's own 401 example carries resource_metadata and scope, and no error.
Expected (no token): Bearer resource_metadata="…", scope="files:read"
Code to reproduce
### 1. The 401 for a request with no tokenimportasyncio, httpx2frommcp.server.auth.providerimportAccessTokenfrommcp.server.auth.settingsimportAuthSettingsfrommcp.server.mcpserverimportMCPServerclassRejectAll:
asyncdefverify_token(self, token: str) ->AccessToken|None: returnNoneasyncdefmain() ->None:
server=MCPServer("s", token_verifier=RejectAll(), auth=AuthSettings(
issuer_url="https://as.example", resource_server_url="http://localhost:8001/mcp",
required_scopes=["files:read"], validate_token_resource=True))
app=server.streamable_http_app(stateless_http=True)
asyncwithapp.router.lifespan_context(app):
asyncwithhttpx2.AsyncClient(transport=httpx2.ASGITransport(app=app), base_url="http://localhost:8001") asc:
r=awaitc.post("/mcp", json={"jsonrpc": "2.0", "id": 1, "method": "tools/list"},
headers={"Accept": "application/json, text/event-stream"})
print(r.status_code, r.headers["www-authenticate"])
asyncio.run(main())
changed the title [-][v2][/-][+]401 and error for a request with no authorization header (for a request with no token) - as per spec no error should be added[/+]on Oct 26, 2026
The missing distinction matters for a client-side reason, not a cosmetic one. error="invalid_token" is a statement about the credential the client presented. When no credential was presented, telling the client its token is invalid invites it to clear a stored credential that was never tried, or to start an interactive re-auth it did not need. The bare challenge plus resource_metadata is the signal that the client should go and discover, and an error code on that response removes the only unambiguous way to tell the two apart.
The three cases are worth asserting as three, because they get conflated in exactly this code path:
No credentials: Bearer resource_metadata="...", scope="..." with no error.
Valid credentials, insufficient scope: Bearer error="insufficient_scope", scope="<the scopes required for this request>".
The scope parameter is the other half. A server that omits it forces a client to request whatever it was first configured with, so any later scope requirement becomes a manual re-consent step rather than an incremental one. Echoing the scopes required for the specific request is what makes step up work, and it also means the value has to be computed per route rather than taken from the settings object: required_scopes is the server's configured set, not necessarily what this tool needs.
Since header assembly is string work, the regression risk is high and failures are invisible to every test that only asserts status 401. A parametrised test over the three cases asserting the exact WWW-Authenticate value catches all of this, including the field ordering questions, and it would have caught this one.
One more thing for whoever picks up the fix: changing _send_auth_error alone won't get the no-token case right.
At d7845c0, BearerAuthBackend.authenticate in src/mcp/server/auth/middleware/bearer_auth.py returns None for a missing or non-Bearer header (L60-61), a rejected token (L68-69), an expired token (L71-72), and a resource mismatch (L74-78). RequireAuthMiddleware only sees "not an AuthenticatedUser" and sends error="invalid_token" for all of them (L117-121). So the backend needs a way to signal "no credentials" separately, for example, returning None only when there is no header and raising AuthenticationError for a bad token. I haven't tested that shape.
Activity
[-][v2][/-][+]401 and error for a request with no authorization header (for a request with no token) - as per spec no error should be added[/+]Ben-Home commentedon Oct 27, 2026
The missing distinction matters for a client-side reason, not a cosmetic one.
error="invalid_token"is a statement about the credential the client presented. When no credential was presented, telling the client its token is invalid invites it to clear a stored credential that was never tried, or to start an interactive re-auth it did not need. The bare challenge plusresource_metadatais the signal that the client should go and discover, and an error code on that response removes the only unambiguous way to tell the two apart.The three cases are worth asserting as three, because they get conflated in exactly this code path:
Bearer resource_metadata="...", scope="..."with noerror.Bearer error="invalid_token", error_description="...", resource_metadata="...".Bearer error="insufficient_scope", scope="<the scopes required for this request>".The scope parameter is the other half. A server that omits it forces a client to request whatever it was first configured with, so any later scope requirement becomes a manual re-consent step rather than an incremental one. Echoing the scopes required for the specific request is what makes step up work, and it also means the value has to be computed per route rather than taken from the settings object:
required_scopesis the server's configured set, not necessarily what this tool needs.Since header assembly is string work, the regression risk is high and failures are invisible to every test that only asserts status 401. A parametrised test over the three cases asserting the exact
WWW-Authenticatevalue catches all of this, including the field ordering questions, and it would have caught this one.sattyamjjain commentedon Oct 28, 2026
One more thing for whoever picks up the fix: changing
_send_auth_erroralone won't get the no-token case right.At d7845c0,
BearerAuthBackend.authenticateinsrc/mcp/server/auth/middleware/bearer_auth.pyreturnsNonefor a missing or non-Bearer header (L60-61), a rejected token (L68-69), an expired token (L71-72), and a resource mismatch (L74-78).RequireAuthMiddlewareonly sees "not an AuthenticatedUser" and sendserror="invalid_token"for all of them (L117-121). So the backend needs a way to signal "no credentials" separately, for example, returningNoneonly when there is no header and raisingAuthenticationErrorfor a bad token. I haven't tested that shape.The 403 path has a similar gap: L125-131 sends
insufficient_scopenaming only the first missing scope, and_send_auth_error(L135-141) takes no scope argument. The 2025-11-25 spec's runtime insufficient-scope section says servers SHOULD include the scopes needed for the current request in thescopeparameter (https://github.com/modelcontextprotocol/modelcontextprotocol/blob/ccaf9102ec5e/docs/specification/2025-11-25/basic/authorization.mdx), so the helper probably needs a scope parameter for both 401 and 403.