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
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:
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).
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).
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.
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.
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.
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.