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
  3. sattyamjjain commented on Oct 28, 2026

    @sattyamjjain
  4. Arun100203 commented on Nov 6, 2026

    @Arun100203

    Hi @yedlurisrinu
    I would like to work on this issue, could you please assign it to me

  5. Arun100203 commented on Nov 6, 2026

    @Arun100203

    I took a look into this. This divergence from RFC 6750 §3.1 is already noted in the test suite under the hosting:auth:missing-401 requirement note in tests/interaction/_requirements.py:

    "for the no-credentials case it emits error="invalid_token",
    which RFC 6750 Section 3.1 says SHOULD NOT appear when no authentication information was presented."
    

    Currently, BearerAuthBackend.authenticate returns None for both missing headers and invalid/expired tokens, causing Starlette to set scope["user"] = UnauthenticatedUser() in both cases. Downstream, RequireAuthMiddleware.call cannot distinguish between the two and unconditionally responds with error="invalid_token", description="Authentication required".

    Proposed Approach:

    1. Differentiate the "no credentials provided" case from the "invalid token provided" case (e.g., by inspecting the Authorization header in the scope/connection or recording the reason on the connection state).
    2. When no Authorization header is present:
      • Return HTTP 401 Unauthorized.
      • Send WWW-Authenticate: Bearer resource_metadata="..." (omitting error and error_description attributes as specified by RFC 6750 §3.1 and RFC 9728).
    3. Update the corresponding snapshot in tests/interaction/auth/test_bearer.py and reconcile the divergence note in tests/interaction/_requirements.py.

    Before opening a PR, what is the preferred response body for this case—an empty body, or a generic message?

    If this approach looks good, I'd be happy to take this on if you can assign the issue to me.

  6. AnnasMazhar commented on Nov 8, 2026

    @AnnasMazhar

    Thanks for the detailed write-up — the proposed approach matches what I had already implemented and tested, so I can hand you the finished patch rather than just an opinion.

    On the body question: I'd recommend the empty body.

    RFC 6750 Section 3.1 says that if the request lacks any authentication information, the resource server SHOULD NOT include an error code or other error information. A JSON body like {"error": "unauthorized"} is other error information, so it reintroduces through the body what the header is required to omit. The machine-readable part of the response is the WWW-Authenticate challenge, which already carries resource_metadata and scope; the body has nothing left to say. If you'd rather have something non-empty for client convenience, a generic {"error": "unauthorized"} is the least-bad option — but I'd keep it out of the header so the challenge stays RFC-shaped.

    The patch is ready. I took your approach 1 exactly — differentiating the two cases by inspecting the Authorization header in the scope — and implemented the rest as you described:

    • RequireAuthMiddleware.__call__: the not-authenticated branch now checks whether an Authorization header was actually present. Absent → no error/error_description, empty body, challenge carries resource_metadata and scope. Present but rejected → error="invalid_token" is kept, plus scope.
    • The 403 branch passes the required scope so it is named in the challenge.
    • _send_auth_error takes an optional scope and emits it as a scope="..." parameter; error/description are now optional.

    One implementation detail worth flagging: the scope string is joined outside the f-string. Nested same-type quotes in an f-string need PEP 701 (3.12+), and the project supports 3.11, so f'scope="{" ".join(scope)}"' would be a SyntaxError on the 3.11 CI job.

    The snapshot in tests/interaction/auth/test_bearer.py is updated, and I reconciled the divergence notes — hosting:auth:missing-401, hosting:auth:scope-403 and hosting:auth:invalid-401 no longer carry a divergence. I also updated docs/run/authorization.md, which showed the old challenge and conflated the no-token and rejected-token cases.

    Locally: 276 passed, 1 xfailed across tests/interaction/auth/, tests/interaction/test_coverage.py, tests/client/test_auth.py and tests/docs_src/test_authorization.py; ruff check and ruff format --check clean.

    The branch is pushed to AnnasMazhar/python-sdk as fix/no-credential-401-scope, and I opened PR #3824 — which the bot auto-closed because the repo only keeps a PR open when the contributor is assigned to the linked issue. So I've assigned this issue to you. If you'd prefer to take it on, the branch and the commit are yours to build on. If you'd rather I land it instead, say so and I'll reopen #3824 once I'm assigned — whichever way you prefer.

  7. AnnasMazhar commented on Nov 8, 2026

    @AnnasMazhar

    Correction to my previous comment: I could not assign this issue to you — my account does not have triage permission on this repo, so the assignment attempt was rejected. That is on the repo side rather than something I can do from here. The ask stands though: if a maintainer assigns #3769 to you, your PR will stay open under the linked-issue policy, and the branch I pushed (fix/no-credential-401-scope on AnnasMazhar/python-sdk) plus the commit are there to build on. If you would rather I land it, assign it to AnnasMazhar instead and I will reopen PR #3824.

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