Repository navigation
Revocation requires client_secret to be present #3508
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)
Description
What happened?
When Claude Code (or any other public client) triggers revocation - revoke handler requires client_secret to be present, as seen here:
| client_secret: str | None |
Per OAuth 2.0 specifications (RFC 6749 Section 2.3 and RFC 7009 Section 5), public clients do not have a client_secret and identify themselves using only client_id.
This leads to issue when public clients do not sent client secret at all.
In Pydantic v2, defining a field as str | None without a default value still marks the field as required (i.e., nullable value, but the key must exist in the request body).
Steps to reproduce
- Authenticate using a public OAuth client (e.g., Claude Code CLI).
- Trigger token revocation (e.g., re-authenticating or logging out).
- The authorization server returns a
400 Bad Requestwith:
{"error":"invalid_request","error_description":"client_secret: Field required"}
Expected behavior
client_secret should be optional so public clients can revoke tokens without sending a client_secret key.
Fix
Adding default value for client_secret:
client_secret: str | None = NoneWorkaround
As workaround I'm overriding Request type:
from mcp.server.auth.handlers import revoke
class FixedRevocationRequest(revoke.RevocationRequest):
client_secret: str | None = None
revoke.RevocationRequest = FixedRevocationRequestExample Code
from mcp.server.auth.handlers.revoke import RevocationRequest
from pydantic import ValidationError
# Simulate a public client request: client_secret parameter omitted entirely
form_no_secret = {"token": "abc", "client_id": "local-scurri-mcp"}
try:
req = RevocationRequest.model_validate(form_no_secret)
print("no-secret: OK ->", req)
except ValidationError as e:
print("no-secret: FAILS ->", e.errors()[0]["msg"])Python & MCP Python SDK
MCP version: 2.2.0
Python version: 3.13.9
Activity
rajeevchandra commentedon Sep 16, 2026
Confirmed the bug on 2.2.0. RevocationRequest.client_secret is defined as str | None with no default, so Pydantic v2 treats it as required — public clients that omit the field entirely get a 400 {"error":"invalid_request","error_description":"client_secret: Field required"}.
Fix is one line in src/mcp/server/auth/handlers/revoke.py:
client_secret: str | None = None
ClientAuthenticator already handles token_endpoint_auth_method == "none" correctly — this is purely a model validation gap. I'd like to fix it if you're open to an outside PR.
HARSHAVARDHAN-RAJU5 commentedon Sep 16, 2026
I confirmed this behavior on 2.x. Omitting
client_secretfrom a/revokerequest for a client registered withtoken_endpoint_auth_method: "none"currently results in a400response withclient_secret: Field required.The cause is that
RevocationRequest.client_secretis defined asstr | Nonebut does not have a default value, making the field required.ClientAuthenticatoralready handles public clients correctly, so the fix is to define it asclient_secret: str | None = None.I’ve implemented this fix and added a regression test that fails before the change and passes after it. The changes are included in #3512.
mulatta commentedon Sep 30, 2026
Same issue here.
A dynamically registered public client (
token_endpoint_auth_method=none) can complete authorization-code + PKCE, obtain and rotate access/refresh tokens, and call the protected MCP resource.Calling
/revokewithoutclient_secretthen returns: 400 invalid_request, client_secret: Field requiredEnzo-TesterQA commentedon Oct 10, 2026
Adding one related detail from running a public-client flow with claude.ai (CIMD) on 2.3.0, in case it helps whoever picks this up.
Making
client_secretoptional inRevocationRequestfixes the 400, but the authorization server metadata built bybuild_metadatastill advertises:without
"none". A public client (Claude registers withtoken_endpoint_auth_method: "none"through CIMD or DCR) that reads the metadata can conclude it isn't allowed to revoke, so sessions may stay open after disconnecting. It's the same situation #2260 described fortoken_endpoint_auth_methods_supported.So the fix probably needs both:
client_secret: str | None = NoneinRevocationRequest."none"inrevocation_endpoint_auth_methods_supported(and intoken_endpoint_auth_methods_supported, for public clients).Our workaround on 2.3.0, in case it's useful to others: we replace the
/revokeroute with a small handler that reusesClientAuthenticator(it already acceptsnone), and add"none"to both lists withOAuthMetadata.model_copy(update=...)before serving the metadata.shearn89 commentedon Oct 16, 2026
I hit this too, running FastMCP's
OAuthProxyin front of an upstream IdP with CIMD and DCR public clients on mcp 2.3.0 and fastmcp 4.1.0.POST /revokewith onlytokenandclient_idreturns400 invalid_request, client_secret: Field required.As noted above, the root cause is that
RevocationRequest.client_secretisstr | Nonewith no default, so pydantic treats the key as required. The metadata has the related gap thatrevocation_endpoint_auth_methods_supportedadvertises onlyclient_secret_postandclient_secret_basic, so a client has no way to learn that unauthenticated revocation is allowed.I'm working around both on my side for now. I'd be happy to send the upstream fix. Could a maintainer assign this to me? The PR would cover:
client_secret: str | None = Nonenoneinrevocation_endpoint_auth_methods_supportedwhen public clients are acceptedIf you'd prefer the metadata change as a separate PR, that's fine too.