From 21c835d3e5e0012e07adb829f852715302c3ced3 Mon Sep 17 00:00:00 2001 From: Sam Tombury Date: Fri, 6 Jun 2025 17:52:39 +0100 Subject: [PATCH] feat: support Cursor OAuth client registration --- src/mcp/server/auth/handlers/authorize.py | 10 +++++----- src/mcp/server/auth/handlers/token.py | 4 ++-- src/mcp/server/auth/provider.py | 6 +++--- src/mcp/shared/auth.py | 6 +++--- tests/client/test_auth.py | 6 +++--- 5 files changed, 16 insertions(+), 16 deletions(-) diff --git a/src/mcp/server/auth/handlers/authorize.py b/src/mcp/server/auth/handlers/authorize.py index 8f3768908..f03afd566 100644 --- a/src/mcp/server/auth/handlers/authorize.py +++ b/src/mcp/server/auth/handlers/authorize.py @@ -2,7 +2,7 @@ from dataclasses import dataclass from typing import Any, Literal -from pydantic import AnyHttpUrl, AnyUrl, BaseModel, Field, RootModel, ValidationError +from pydantic import AnyUrl, BaseModel, Field, RootModel, ValidationError from starlette.datastructures import FormData, QueryParams from starlette.requests import Request from starlette.responses import RedirectResponse, Response @@ -29,7 +29,7 @@ class AuthorizationRequest(BaseModel): # See https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.1 client_id: str = Field(..., description="The client ID") - redirect_uri: AnyHttpUrl | None = Field( + redirect_uri: AnyUrl | None = Field( None, description="URL to redirect to after authorization" ) @@ -68,8 +68,8 @@ def best_effort_extract_string( return None -class AnyHttpUrlModel(RootModel[AnyHttpUrl]): - root: AnyHttpUrl +class AnyUrlModel(RootModel[AnyUrl]): + root: AnyUrl @dataclass @@ -116,7 +116,7 @@ async def error_response( if params is not None and "redirect_uri" not in params: raw_redirect_uri = None else: - raw_redirect_uri = AnyHttpUrlModel.model_validate( + raw_redirect_uri = AnyUrlModel.model_validate( best_effort_extract_string("redirect_uri", params) ).root redirect_uri = client.validate_redirect_uri(raw_redirect_uri) diff --git a/src/mcp/server/auth/handlers/token.py b/src/mcp/server/auth/handlers/token.py index 94a5c4de3..abea2bd41 100644 --- a/src/mcp/server/auth/handlers/token.py +++ b/src/mcp/server/auth/handlers/token.py @@ -4,7 +4,7 @@ from dataclasses import dataclass from typing import Annotated, Any, Literal -from pydantic import AnyHttpUrl, BaseModel, Field, RootModel, ValidationError +from pydantic import AnyHttpUrl, AnyUrl, BaseModel, Field, RootModel, ValidationError from starlette.requests import Request from mcp.server.auth.errors import ( @@ -27,7 +27,7 @@ class AuthorizationCodeRequest(BaseModel): # See https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.3 grant_type: Literal["authorization_code"] code: str = Field(..., description="The authorization code") - redirect_uri: AnyHttpUrl | None = Field( + redirect_uri: AnyUrl | None = Field( None, description="Must be the same as redirect URI provided in /authorize" ) client_id: str diff --git a/src/mcp/server/auth/provider.py b/src/mcp/server/auth/provider.py index be1ac1dbc..9f107f71b 100644 --- a/src/mcp/server/auth/provider.py +++ b/src/mcp/server/auth/provider.py @@ -2,7 +2,7 @@ from typing import Generic, Literal, Protocol, TypeVar from urllib.parse import parse_qs, urlencode, urlparse, urlunparse -from pydantic import AnyHttpUrl, BaseModel +from pydantic import AnyUrl, BaseModel from mcp.shared.auth import ( OAuthClientInformationFull, @@ -14,7 +14,7 @@ class AuthorizationParams(BaseModel): state: str | None scopes: list[str] | None code_challenge: str - redirect_uri: AnyHttpUrl + redirect_uri: AnyUrl redirect_uri_provided_explicitly: bool @@ -24,7 +24,7 @@ class AuthorizationCode(BaseModel): expires_at: float client_id: str code_challenge: str - redirect_uri: AnyHttpUrl + redirect_uri: AnyUrl redirect_uri_provided_explicitly: bool diff --git a/src/mcp/shared/auth.py b/src/mcp/shared/auth.py index 22f8a971d..1c988a5e2 100644 --- a/src/mcp/shared/auth.py +++ b/src/mcp/shared/auth.py @@ -1,6 +1,6 @@ from typing import Any, Literal -from pydantic import AnyHttpUrl, BaseModel, Field +from pydantic import AnyHttpUrl, AnyUrl, BaseModel, Field class OAuthToken(BaseModel): @@ -32,7 +32,7 @@ class OAuthClientMetadata(BaseModel): for the full specification. """ - redirect_uris: list[AnyHttpUrl] = Field(..., min_length=1) + redirect_uris: list[AnyUrl] = Field(..., min_length=1) # token_endpoint_auth_method: this implementation only supports none & # client_secret_post; # ie: we do not support client_secret_basic @@ -71,7 +71,7 @@ def validate_scope(self, requested_scope: str | None) -> list[str] | None: raise InvalidScopeError(f"Client was not registered with scope {scope}") return requested_scopes - def validate_redirect_uri(self, redirect_uri: AnyHttpUrl | None) -> AnyHttpUrl: + def validate_redirect_uri(self, redirect_uri: AnyUrl | None) -> AnyUrl: if redirect_uri is not None: # Validate redirect_uri against client's registered redirect URIs if redirect_uri not in self.redirect_uris: diff --git a/tests/client/test_auth.py b/tests/client/test_auth.py index 2edaff946..c663bddcc 100644 --- a/tests/client/test_auth.py +++ b/tests/client/test_auth.py @@ -11,7 +11,7 @@ import httpx import pytest from inline_snapshot import snapshot -from pydantic import AnyHttpUrl +from pydantic import AnyHttpUrl, AnyUrl from mcp.client.auth import OAuthClientProvider from mcp.server.auth.routes import build_metadata @@ -52,7 +52,7 @@ def mock_storage(): @pytest.fixture def client_metadata(): return OAuthClientMetadata( - redirect_uris=[AnyHttpUrl("http://localhost:3000/callback")], + redirect_uris=[AnyUrl("http://localhost:3000/callback")], client_name="Test Client", grant_types=["authorization_code", "refresh_token"], response_types=["code"], @@ -79,7 +79,7 @@ def oauth_client_info(): return OAuthClientInformationFull( client_id="test_client_id", client_secret="test_client_secret", - redirect_uris=[AnyHttpUrl("http://localhost:3000/callback")], + redirect_uris=[AnyUrl("http://localhost:3000/callback")], client_name="Test Client", grant_types=["authorization_code", "refresh_token"], response_types=["code"],