Skip to content

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

Open

Description

@yedlurisrinu

What happened?

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.

Output: 401 Bearer error="invalid_token", error_description="Authentication required", resource_metadata="http://localhost:8001/.well-known/oauth-protected-resource/mcp"

What did you expect?

1. The 401 for a request with no token

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 token
import asyncio, httpx2
from mcp.server.auth.provider import AccessToken
from mcp.server.auth.settings import AuthSettings
from mcp.server.mcpserver import MCPServer

class RejectAll:
    async def verify_token(self, token: str) -> AccessToken | None: return None

async def main() -> 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)
    async with app.router.lifespan_context(app):
        async with httpx2.AsyncClient(transport=httpx2.ASGITransport(app=app), base_url="http://localhost:8001") as c:
            r = await c.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())

SDK version

2.3.0

Area

Server

Activity

  1. 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
  2. Ben-Home commented on Oct 27, 2026

    @Ben-Home

    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:

    1. No credentials: Bearer resource_metadata="...", scope="..." with no error.
    2. Invalid credentials: Bearer error="invalid_token", error_description="...", resource_metadata="...".
    3. 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.

  3. sattyamjjain commented on Oct 28, 2026

    @sattyamjjain

    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.

    The 403 path has a similar gap: L125-131 sends insufficient_scope naming 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 the scope parameter (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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingv2Affects the v2 line (2.x on main)

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions