From dc7bb24d3748737cb5cae3ba15837de5ed93433f Mon Sep 17 00:00:00 2001 From: Jon Shea Date: Thu, 28 Aug 2025 13:17:12 -0400 Subject: [PATCH] Accept additional response_types values from OAuth servers OAuth servers may return additional response_types beyond what the client requested (e.g., ["code", "none"] instead of just ["code"]). Per RFC 7591 Section 3.2.1, servers can modify registration metadata and return all registered values including server-provisioned fields. For example, Keycloak returns ["code", "none"] even when just ["code"] was requested. - Changed response_types field from list[Literal["code"]] to list[str] - Added validation in registration handler to ensure "code" is present - Added tests for response_types flexibility This fixes compatibility issues with OAuth servers that return additional response_types while maintaining MCP's requirement for the "code" flow. --- src/mcp/server/auth/handlers/register.py | 11 ++++ src/mcp/shared/auth.py | 5 +- .../fastmcp/auth/test_auth_integration.py | 56 +++++++++++++++++++ 3 files changed, 70 insertions(+), 2 deletions(-) diff --git a/src/mcp/server/auth/handlers/register.py b/src/mcp/server/auth/handlers/register.py index e6d99e66d..963e86b5a 100644 --- a/src/mcp/server/auth/handlers/register.py +++ b/src/mcp/server/auth/handlers/register.py @@ -77,6 +77,17 @@ async def handle(self, request: Request) -> Response: status_code=400, ) + # The MCP spec requires servers to use the authorization `code` flow + # with PKCE + if "code" not in client_metadata.response_types: + return PydanticJSONResponse( + content=RegistrationErrorResponse( + error="invalid_client_metadata", + error_description="response_types must include 'code' for authorization_code grant", + ), + status_code=400, + ) + client_id_issued_at = int(time.time()) client_secret_expires_at = ( client_id_issued_at + self.options.client_secret_expiry_seconds diff --git a/src/mcp/shared/auth.py b/src/mcp/shared/auth.py index 6bf15b531..8ebc76864 100644 --- a/src/mcp/shared/auth.py +++ b/src/mcp/shared/auth.py @@ -51,8 +51,9 @@ class OAuthClientMetadata(BaseModel): "authorization_code", "refresh_token", ] - # this implementation only supports code; ie: it does not support implicit grants - response_types: list[Literal["code"]] = ["code"] + # The MCP spec requires the "code" response type, but OAuth + # servers may also return additional types they support + response_types: list[str] = ["code"] scope: str | None = None # these fields are currently unused, but we support & store them for potential diff --git a/tests/server/fastmcp/auth/test_auth_integration.py b/tests/server/fastmcp/auth/test_auth_integration.py index e4bb17397..923926508 100644 --- a/tests/server/fastmcp/auth/test_auth_integration.py +++ b/tests/server/fastmcp/auth/test_auth_integration.py @@ -942,6 +942,62 @@ async def test_client_registration_invalid_grant_type(self, test_client: httpx.A assert error_data["error"] == "invalid_client_metadata" assert error_data["error_description"] == "grant_types must be authorization_code and refresh_token" + @pytest.mark.anyio + async def test_client_registration_with_additional_response_types( + self, test_client: httpx.AsyncClient, mock_oauth_provider: MockOAuthProvider + ): + """Test that registration accepts additional response_types values alongside 'code'.""" + client_metadata = { + "redirect_uris": ["https://client.example.com/callback"], + "client_name": "Test Client", + "grant_types": ["authorization_code", "refresh_token"], + "response_types": ["code", "none"], # Keycloak-style response with additional value + } + + response = await test_client.post("/register", json=client_metadata) + assert response.status_code == 201 + data = response.json() + + client = await mock_oauth_provider.get_client(data["client_id"]) + assert client is not None + assert "code" in client.response_types + + @pytest.mark.anyio + async def test_client_registration_response_types_without_code(self, test_client: httpx.AsyncClient): + """Test that registration rejects response_types that don't include 'code'.""" + client_metadata = { + "redirect_uris": ["https://client.example.com/callback"], + "client_name": "Test Client", + "grant_types": ["authorization_code", "refresh_token"], + "response_types": ["token", "none", "nonsense-string"], + } + + response = await test_client.post("/register", json=client_metadata) + assert response.status_code == 400 + error_data = response.json() + assert "error" in error_data + assert error_data["error"] == "invalid_client_metadata" + assert "response_types must include 'code'" in error_data["error_description"] + + @pytest.mark.anyio + async def test_client_registration_default_response_types( + self, test_client: httpx.AsyncClient, mock_oauth_provider: MockOAuthProvider + ): + """Test that registration uses default response_types of ['code'] when not specified.""" + client_metadata = { + "redirect_uris": ["https://client.example.com/callback"], + "client_name": "Test Client", + "grant_types": ["authorization_code", "refresh_token"], + # response_types not specified, should default to ["code"] + } + + response = await test_client.post("/register", json=client_metadata) + assert response.status_code == 201 + data = response.json() + + assert "response_types" in data + assert data["response_types"] == ["code"] + class TestAuthorizeEndpointErrors: """Test error handling in the OAuth authorization endpoint."""