Repository navigation
Auth server /token and /revoke return the wrong OAuth error codes (RFC 6749 §5.2) #3723
Description
Initial Checks
- I confirm that I'm using the newest release of my line (the latest 2.x, or the latest 1.x if I'm still on v1)
- I confirm that I searched for my issue in https://github.com/modelcontextprotocol/python-sdk/issues before opening this issue
Release line
2.x (current stable). The same code is on v1.x.
Description
The built-in authorization server's error responses don't match RFC 6749 §5.2, which OAuth 2.1 and RFC 7009 (revocation) reuse:
| Case | Returns | RFC 6749 §5.2 |
|---|---|---|
/token, grant the server supports but this client isn't registered for |
unsupported_grant_type |
unauthorized_client |
/token, grant the server doesn't support (client_credentials, password) |
invalid_request with pydantic's union error text |
unsupported_grant_type |
/revoke, client authentication fails |
unauthorized_client |
invalid_client |
/token and /revoke, Basic authentication fails |
401 without WWW-Authenticate |
401 with WWW-Authenticate matching the scheme ("MUST") |
The first two are swapped. The server already knows which grants it supports; it advertises grant_types_supported: ["authorization_code", "refresh_token"] in its metadata. The jwt-bearer path in token.py already returns unauthorized_client for the first case, with a comment saying so (around line 257); the client_info.grant_types check around line 135 returns unsupported_grant_type.
For /revoke, #1481 changed /token to invalid_client for failed client authentication but not RevocationHandler, whose error type only allows invalid_request or unauthorized_client. #1481 also left out WWW-Authenticate because only client_secret_post was supported then; client_secret_basic is supported now.
Clients that branch on the error code, for example to re-register only on invalid_client or to stop retrying on unauthorized_client, behave incorrectly against these responses.
Possible fix: return unauthorized_client at the grant_types check; check grant_type against the supported grants before validating the request union and return unsupported_grant_type; return invalid_client from /revoke; add WWW-Authenticate: Basic to the 401 when the request used Authorization: Basic. I'd be glad to send a PR if this is assigned to me.
Example Code
import base64
from pydantic import AnyHttpUrl
from starlette.applications import Starlette
from starlette.testclient import TestClient
from mcp.server.auth.routes import create_auth_routes
from mcp.server.auth.settings import RevocationOptions
from mcp.shared.auth import OAuthClientInformationFull
class Provider:
clients = {
"c1": OAuthClientInformationFull(
client_id="c1", client_secret="right", redirect_uris=["http://localhost/cb"],
grant_types=["authorization_code"], token_endpoint_auth_method="client_secret_basic",
)
}
async def get_client(self, client_id):
return self.clients.get(client_id)
async def load_refresh_token(self, client, token):
return None
app = Starlette(routes=create_auth_routes(
Provider(), AnyHttpUrl("http://localhost:9000"), revocation_options=RevocationOptions(enabled=True)
))
def basic(secret):
return {"authorization": "Basic " + base64.b64encode(f"c1:{secret}".encode()).decode()}
with TestClient(app) as c:
for grant in ("refresh_token", "client_credentials"):
r = c.post("/token", headers=basic("right"), data={"grant_type": grant, "refresh_token": "rt", "client_id": "c1"})
print("/token", grant, r.status_code, r.json()["error"])
for path in ("/token", "/revoke"):
r = c.post(path, headers=basic("WRONG"), data={"grant_type": "refresh_token", "refresh_token": "x", "token": "x", "client_id": "c1"})
print(path, "wrong secret", r.status_code, r.json()["error"], r.headers.get("www-authenticate"))Output:
/token refresh_token 400 unsupported_grant_type
/token client_credentials 400 invalid_request
/token wrong secret 401 invalid_client None
/revoke wrong secret 401 unauthorized_client None
Expected unauthorized_client, unsupported_grant_type, then invalid_client with a WWW-Authenticate: Basic ... header on both 401s.
Python & MCP Python SDK
Python 3.13
mcp 2.3.0 (handlers/token.py and handlers/revoke.py identical on main)
Found while testing the auth server endpoints with AI assistance; I ran the reproduction and checked the output myself.
Activity