Description
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
[-][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
sattyamjjain commentedon Oct 28, 2026
Arun100203 commentedon Nov 6, 2026
Hi @yedlurisrinu
I would like to work on this issue, could you please assign it to me
Arun100203 commentedon Nov 6, 2026
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:
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:
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.
AnnasMazhar commentedon Nov 8, 2026
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 theWWW-Authenticatechallenge, which already carriesresource_metadataandscope; 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
Authorizationheader in the scope — and implemented the rest as you described:RequireAuthMiddleware.__call__: the not-authenticated branch now checks whether anAuthorizationheader was actually present. Absent → noerror/error_description, empty body, challenge carriesresource_metadataandscope. Present but rejected →error="invalid_token"is kept, plusscope._send_auth_errortakes an optionalscopeand emits it as ascope="..."parameter;error/descriptionare 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 aSyntaxErroron the 3.11 CI job.The snapshot in
tests/interaction/auth/test_bearer.pyis updated, and I reconciled the divergence notes —hosting:auth:missing-401,hosting:auth:scope-403andhosting:auth:invalid-401no longer carry adivergence. I also updateddocs/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.pyandtests/docs_src/test_authorization.py;ruff checkandruff format --checkclean.The branch is pushed to
AnnasMazhar/python-sdkasfix/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.AnnasMazhar commentedon Nov 8, 2026
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.