From 91c532a182f880c16be3bee7d9418318b537cc5c Mon Sep 17 00:00:00 2001 From: Varshith Puli Date: Tue, 28 Jul 2026 22:09:17 +0530 Subject: [PATCH 01/13] Validates registered redirect_uris for DCR are a secure schema with no fragments --- src/mcp/server/auth/handlers/register.py | 15 +++++++++ src/mcp/server/auth/routes.py | 26 +++++++++++++++ tests/server/auth/test_routes.py | 42 +++++++++++++++++++++++- 3 files changed, 82 insertions(+), 1 deletion(-) diff --git a/src/mcp/server/auth/handlers/register.py b/src/mcp/server/auth/handlers/register.py index 7fb14b2c43..0f9378e458 100644 --- a/src/mcp/server/auth/handlers/register.py +++ b/src/mcp/server/auth/handlers/register.py @@ -9,6 +9,7 @@ from starlette.responses import Response from mcp.server.auth.errors import stringify_pydantic_error +from mcp.server.auth.routes import validate_redirect_uri from mcp.server.auth.json_response import PydanticJSONResponse from mcp.server.auth.provider import OAuthAuthorizationServerProvider, RegistrationError, RegistrationErrorCode from mcp.server.auth.settings import ClientRegistrationOptions @@ -35,6 +36,20 @@ async def handle(self, request: Request) -> Response: body = await request.body() client_metadata = OAuthClientMetadata.model_validate_json(body) + # Validate redirect_uris per RFC 7591 section 2 + if client_metadata.redirect_uris: + for uri in client_metadata.redirect_uris: + try: + validate_redirect_uri(uri) + except ValueError as e: + return PydanticJSONResponse( + content=RegistrationErrorResponse( + error="invalid_redirect_uri", + error_description=str(e), + ), + status_code=400, + ) + # Scope validation is handled below except ValidationError as validation_error: return PydanticJSONResponse( diff --git a/src/mcp/server/auth/routes.py b/src/mcp/server/auth/routes.py index fa88dddcf4..1764e95fed 100644 --- a/src/mcp/server/auth/routes.py +++ b/src/mcp/server/auth/routes.py @@ -21,6 +21,32 @@ from mcp.shared.inbound import MCP_PROTOCOL_VERSION_HEADER +def validate_redirect_uri(url: AnyHttpUrl): + """Validate a registered redirect_uri for DCR. + + RFC 9700 section 4.1.1 and RFC 7591 section 2 require HTTPS for + redirect_uris, with an HTTP loopback exception for local development. + + Args: + url: The redirect URI to validate. + + Raises: + ValueError: If the redirect URI uses an unsafe scheme or contains + a fragment. + """ + if url.scheme != "https" and url.host not in ( + "localhost", + "127.0.0.1", + "[::1]", + ): + raise ValueError( + "Redirect URI must use HTTPS (or HTTP loopback for local development)" + ) + + if url.fragment is not None: + raise ValueError("Redirect URI must not contain a fragment") + + def validate_issuer_url(url: AnyHttpUrl): """Validate that the issuer URL meets OAuth 2.0 requirements. diff --git a/tests/server/auth/test_routes.py b/tests/server/auth/test_routes.py index 58685c64c7..5cd3c8748b 100644 --- a/tests/server/auth/test_routes.py +++ b/tests/server/auth/test_routes.py @@ -1,7 +1,7 @@ import pytest from pydantic import AnyHttpUrl -from mcp.server.auth.routes import build_metadata, validate_issuer_url +from mcp.server.auth.routes import build_metadata, validate_issuer_url, validate_redirect_uri from mcp.server.auth.settings import AuthSettings, ClientRegistrationOptions, RevocationOptions @@ -70,3 +70,43 @@ def test_build_metadata_serves_issuer_without_trailing_slash(): assert served["issuer"] == "https://as.example.com" assert served["authorization_endpoint"] == "https://as.example.com/authorize" assert served["token_endpoint"] == "https://as.example.com/token" + +def test_validate_redirect_uri_https_allowed(): + validate_redirect_uri(AnyHttpUrl('https://example.com/cb')) + + +def test_validate_redirect_uri_http_localhost_allowed(): + validate_redirect_uri(AnyHttpUrl('http://localhost:3000/cb')) + + +def test_validate_redirect_uri_http_127_0_0_1_allowed(): + validate_redirect_uri(AnyHttpUrl('http://127.0.0.1:8080/cb')) + + +def test_validate_redirect_uri_http_ipv6_loopback_allowed(): + validate_redirect_uri(AnyHttpUrl('http://[::1]:9090/cb')) + + +def test_validate_redirect_uri_javascript_scheme_rejected(): + with pytest.raises(ValueError, match='Redirect URI must use HTTPS'): + validate_redirect_uri(AnyHttpUrl('javascript:alert(1)')) + + +def test_validate_redirect_uri_file_scheme_rejected(): + with pytest.raises(ValueError, match='Redirect URI must use HTTPS'): + validate_redirect_uri(AnyHttpUrl('file:///etc/passwd')) + + +def test_validate_redirect_uri_http_non_loopback_rejected(): + with pytest.raises(ValueError, match='Redirect URI must use HTTPS'): + validate_redirect_uri(AnyHttpUrl('http://evil.com/cb')) + + +def test_validate_redirect_uri_fragment_rejected(): + with pytest.raises(ValueError, match='Redirect URI must not contain a fragment'): + validate_redirect_uri(AnyHttpUrl('https://example.com/cb#frag')) + + +def test_validate_redirect_uri_empty_fragment_rejected(): + with pytest.raises(ValueError, match='Redirect URI must not contain a fragment'): + validate_redirect_uri(AnyHttpUrl('https://example.com/cb#')) From f8a5c2340c38f1cf42d769460df7e3128eac91c4 Mon Sep 17 00:00:00 2001 From: Varshith Puli Date: Wed, 29 Jul 2026 20:37:13 +0530 Subject: [PATCH 02/13] Fix circular import by moving validate_redirect_uri to url_validators module The original implementation put validate_redirect_uri in routes.py, which caused a circular import: register.py imports from routes, and routes imports from other modules that import back. Moving the validation functions to a dedicated url_validators.py module breaks the cycle. - validate_redirect_uri now lives in url_validators.py alongside validate_issuer_url (previously in routes.py) - register.py imports from url_validators instead of routes - test_routes.py updated to import from url_validators - Ruff format fixes applied (single-line raise, double-quote match strings) --- src/mcp/server/auth/__init__.py | 2 ++ src/mcp/server/auth/handlers/register.py | 2 +- src/mcp/server/auth/routes.py | 26 -------------- src/mcp/server/auth/url_validators.py | 46 ++++++++++++++++++++++++ tests/server/auth/test_routes.py | 13 +++---- 5 files changed, 56 insertions(+), 33 deletions(-) create mode 100644 src/mcp/server/auth/url_validators.py diff --git a/src/mcp/server/auth/__init__.py b/src/mcp/server/auth/__init__.py index 61b60e3487..34f2e3e1c9 100644 --- a/src/mcp/server/auth/__init__.py +++ b/src/mcp/server/auth/__init__.py @@ -1 +1,3 @@ """MCP OAuth server authorization components.""" + +from .url_validators import validate_issuer_url, validate_redirect_uri diff --git a/src/mcp/server/auth/handlers/register.py b/src/mcp/server/auth/handlers/register.py index 0f9378e458..ff41d95bd0 100644 --- a/src/mcp/server/auth/handlers/register.py +++ b/src/mcp/server/auth/handlers/register.py @@ -9,7 +9,7 @@ from starlette.responses import Response from mcp.server.auth.errors import stringify_pydantic_error -from mcp.server.auth.routes import validate_redirect_uri +from mcp.server.auth.url_validators import validate_redirect_uri from mcp.server.auth.json_response import PydanticJSONResponse from mcp.server.auth.provider import OAuthAuthorizationServerProvider, RegistrationError, RegistrationErrorCode from mcp.server.auth.settings import ClientRegistrationOptions diff --git a/src/mcp/server/auth/routes.py b/src/mcp/server/auth/routes.py index 1764e95fed..fa88dddcf4 100644 --- a/src/mcp/server/auth/routes.py +++ b/src/mcp/server/auth/routes.py @@ -21,32 +21,6 @@ from mcp.shared.inbound import MCP_PROTOCOL_VERSION_HEADER -def validate_redirect_uri(url: AnyHttpUrl): - """Validate a registered redirect_uri for DCR. - - RFC 9700 section 4.1.1 and RFC 7591 section 2 require HTTPS for - redirect_uris, with an HTTP loopback exception for local development. - - Args: - url: The redirect URI to validate. - - Raises: - ValueError: If the redirect URI uses an unsafe scheme or contains - a fragment. - """ - if url.scheme != "https" and url.host not in ( - "localhost", - "127.0.0.1", - "[::1]", - ): - raise ValueError( - "Redirect URI must use HTTPS (or HTTP loopback for local development)" - ) - - if url.fragment is not None: - raise ValueError("Redirect URI must not contain a fragment") - - def validate_issuer_url(url: AnyHttpUrl): """Validate that the issuer URL meets OAuth 2.0 requirements. diff --git a/src/mcp/server/auth/url_validators.py b/src/mcp/server/auth/url_validators.py new file mode 100644 index 0000000000..dbd2cf58a6 --- /dev/null +++ b/src/mcp/server/auth/url_validators.py @@ -0,0 +1,46 @@ +\"\"\"OAuth 2.0 URL validation helpers for MCP authorization servers. + +RFC 9700 4.1.1 and RFC 7591 2 require HTTPS for authorization endpoint URLs +and registered redirect_uris, with an HTTP loopback exception for local +development. +\"\"\" + +from pydantic import AnyHttpUrl + + +def validate_issuer_url(url: AnyHttpUrl): + \"\"\"Validate that the issuer URL meets OAuth 2.0 requirements. + + Args: + url: The issuer URL to validate. + + Raises: + ValueError: If the issuer URL is invalid. + \"\"\" + if url.scheme != "https" and url.host not in ("localhost", "127.0.0.1", "[::1]"): + raise ValueError("Issuer URL must be HTTPS") + + if url.fragment: + raise ValueError("Issuer URL must not have a fragment") + if url.query: + raise ValueError("Issuer URL must not have a query string") + + +def validate_redirect_uri(url: AnyHttpUrl): + \"\"\"Validate a registered redirect_uri for DCR. + + RFC 9700 section 4.1.1 and RFC 7591 section 2 require HTTPS for + redirect_uris, with an HTTP loopback exception for local development. + + Args: + url: The redirect URI to validate. + + Raises: + ValueError: If the redirect URI uses an unsafe scheme or contains + a fragment. + \"\"\" + if url.scheme != "https" and url.host not in ("localhost", "127.0.0.1", "[::1]"): + raise ValueError("Redirect URI must use HTTPS (or HTTP loopback for local development)") + + if url.fragment is not None: + raise ValueError("Redirect URI must not contain a fragment") diff --git a/tests/server/auth/test_routes.py b/tests/server/auth/test_routes.py index 5cd3c8748b..a2c627d393 100644 --- a/tests/server/auth/test_routes.py +++ b/tests/server/auth/test_routes.py @@ -1,7 +1,8 @@ import pytest from pydantic import AnyHttpUrl -from mcp.server.auth.routes import build_metadata, validate_issuer_url, validate_redirect_uri +from mcp.server.auth.routes import build_metadata, validate_issuer_url +from mcp.server.auth.url_validators import validate_redirect_uri from mcp.server.auth.settings import AuthSettings, ClientRegistrationOptions, RevocationOptions @@ -88,25 +89,25 @@ def test_validate_redirect_uri_http_ipv6_loopback_allowed(): def test_validate_redirect_uri_javascript_scheme_rejected(): - with pytest.raises(ValueError, match='Redirect URI must use HTTPS'): + with pytest.raises(ValueError, match="Redirect URI must use HTTPS"): validate_redirect_uri(AnyHttpUrl('javascript:alert(1)')) def test_validate_redirect_uri_file_scheme_rejected(): - with pytest.raises(ValueError, match='Redirect URI must use HTTPS'): + with pytest.raises(ValueError, match="Redirect URI must use HTTPS"): validate_redirect_uri(AnyHttpUrl('file:///etc/passwd')) def test_validate_redirect_uri_http_non_loopback_rejected(): - with pytest.raises(ValueError, match='Redirect URI must use HTTPS'): + with pytest.raises(ValueError, match="Redirect URI must use HTTPS"): validate_redirect_uri(AnyHttpUrl('http://evil.com/cb')) def test_validate_redirect_uri_fragment_rejected(): - with pytest.raises(ValueError, match='Redirect URI must not contain a fragment'): + with pytest.raises(ValueError, match="Redirect URI must not contain a fragment"): validate_redirect_uri(AnyHttpUrl('https://example.com/cb#frag')) def test_validate_redirect_uri_empty_fragment_rejected(): - with pytest.raises(ValueError, match='Redirect URI must not contain a fragment'): + with pytest.raises(ValueError, match="Redirect URI must not contain a fragment"): validate_redirect_uri(AnyHttpUrl('https://example.com/cb#')) From 6861a77e884fd0dd7b4fee38b45e8b9504dcb547 Mon Sep 17 00:00:00 2001 From: Varshith Puli Date: Wed, 29 Jul 2026 20:42:45 +0530 Subject: [PATCH 03/13] Fix BOM, CRLF line endings, and escaped quotes in url_validators --- src/mcp/server/auth/url_validators.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/mcp/server/auth/url_validators.py b/src/mcp/server/auth/url_validators.py index dbd2cf58a6..4975ddb208 100644 --- a/src/mcp/server/auth/url_validators.py +++ b/src/mcp/server/auth/url_validators.py @@ -1,22 +1,22 @@ -\"\"\"OAuth 2.0 URL validation helpers for MCP authorization servers. +"""OAuth 2.0 URL validation helpers for MCP authorization servers. RFC 9700 4.1.1 and RFC 7591 2 require HTTPS for authorization endpoint URLs and registered redirect_uris, with an HTTP loopback exception for local development. -\"\"\" +""" from pydantic import AnyHttpUrl def validate_issuer_url(url: AnyHttpUrl): - \"\"\"Validate that the issuer URL meets OAuth 2.0 requirements. + """Validate that the issuer URL meets OAuth 2.0 requirements. Args: url: The issuer URL to validate. Raises: ValueError: If the issuer URL is invalid. - \"\"\" + """ if url.scheme != "https" and url.host not in ("localhost", "127.0.0.1", "[::1]"): raise ValueError("Issuer URL must be HTTPS") @@ -27,7 +27,7 @@ def validate_issuer_url(url: AnyHttpUrl): def validate_redirect_uri(url: AnyHttpUrl): - \"\"\"Validate a registered redirect_uri for DCR. + """Validate a registered redirect_uri for DCR. RFC 9700 section 4.1.1 and RFC 7591 section 2 require HTTPS for redirect_uris, with an HTTP loopback exception for local development. @@ -38,7 +38,7 @@ def validate_redirect_uri(url: AnyHttpUrl): Raises: ValueError: If the redirect URI uses an unsafe scheme or contains a fragment. - \"\"\" + """ if url.scheme != "https" and url.host not in ("localhost", "127.0.0.1", "[::1]"): raise ValueError("Redirect URI must use HTTPS (or HTTP loopback for local development)") From bd0e4d6d72af250dedee0a1c916a9cbf9320c77a Mon Sep 17 00:00:00 2001 From: Varshith Puli Date: Wed, 29 Jul 2026 22:02:35 +0530 Subject: [PATCH 04/13] Apply ruff formatting to test_routes.py --- tests/server/auth/test_routes.py | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/tests/server/auth/test_routes.py b/tests/server/auth/test_routes.py index a2c627d393..ede29b3e78 100644 --- a/tests/server/auth/test_routes.py +++ b/tests/server/auth/test_routes.py @@ -2,8 +2,8 @@ from pydantic import AnyHttpUrl from mcp.server.auth.routes import build_metadata, validate_issuer_url -from mcp.server.auth.url_validators import validate_redirect_uri from mcp.server.auth.settings import AuthSettings, ClientRegistrationOptions, RevocationOptions +from mcp.server.auth.url_validators import validate_redirect_uri def test_validate_issuer_url_https_allowed(): @@ -72,42 +72,43 @@ def test_build_metadata_serves_issuer_without_trailing_slash(): assert served["authorization_endpoint"] == "https://as.example.com/authorize" assert served["token_endpoint"] == "https://as.example.com/token" + def test_validate_redirect_uri_https_allowed(): - validate_redirect_uri(AnyHttpUrl('https://example.com/cb')) + validate_redirect_uri(AnyHttpUrl("https://example.com/cb")) def test_validate_redirect_uri_http_localhost_allowed(): - validate_redirect_uri(AnyHttpUrl('http://localhost:3000/cb')) + validate_redirect_uri(AnyHttpUrl("http://localhost:3000/cb")) def test_validate_redirect_uri_http_127_0_0_1_allowed(): - validate_redirect_uri(AnyHttpUrl('http://127.0.0.1:8080/cb')) + validate_redirect_uri(AnyHttpUrl("http://127.0.0.1:8080/cb")) def test_validate_redirect_uri_http_ipv6_loopback_allowed(): - validate_redirect_uri(AnyHttpUrl('http://[::1]:9090/cb')) + validate_redirect_uri(AnyHttpUrl("http://[::1]:9090/cb")) def test_validate_redirect_uri_javascript_scheme_rejected(): with pytest.raises(ValueError, match="Redirect URI must use HTTPS"): - validate_redirect_uri(AnyHttpUrl('javascript:alert(1)')) + validate_redirect_uri(AnyHttpUrl("javascript:alert(1)")) def test_validate_redirect_uri_file_scheme_rejected(): with pytest.raises(ValueError, match="Redirect URI must use HTTPS"): - validate_redirect_uri(AnyHttpUrl('file:///etc/passwd')) + validate_redirect_uri(AnyHttpUrl("file:///etc/passwd")) def test_validate_redirect_uri_http_non_loopback_rejected(): with pytest.raises(ValueError, match="Redirect URI must use HTTPS"): - validate_redirect_uri(AnyHttpUrl('http://evil.com/cb')) + validate_redirect_uri(AnyHttpUrl("http://evil.com/cb")) def test_validate_redirect_uri_fragment_rejected(): with pytest.raises(ValueError, match="Redirect URI must not contain a fragment"): - validate_redirect_uri(AnyHttpUrl('https://example.com/cb#frag')) + validate_redirect_uri(AnyHttpUrl("https://example.com/cb#frag")) def test_validate_redirect_uri_empty_fragment_rejected(): with pytest.raises(ValueError, match="Redirect URI must not contain a fragment"): - validate_redirect_uri(AnyHttpUrl('https://example.com/cb#')) + validate_redirect_uri(AnyHttpUrl("https://example.com/cb#")) From 313376be455ede00efb8b8c124c96f3dc40e62c3 Mon Sep 17 00:00:00 2001 From: Varshith Puli Date: Thu, 30 Jul 2026 00:00:27 +0530 Subject: [PATCH 05/13] Only reject non-HTTP(S) schemes and fragments for redirect URIs The SDK intentionally accepts non-loopback HTTP redirect URIs per existing tests (test_a_non_loopback_http_redirect_uri_is_accepted). Narrow scope to only reject dangerous schemes (javascript:, data:, file:, etc.) and fragments, matching the SDK's existing policy. --- src/mcp/server/auth/url_validators.py | 4 ++-- tests/server/auth/test_routes.py | 5 ++--- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/src/mcp/server/auth/url_validators.py b/src/mcp/server/auth/url_validators.py index 4975ddb208..60b6f5d55e 100644 --- a/src/mcp/server/auth/url_validators.py +++ b/src/mcp/server/auth/url_validators.py @@ -39,8 +39,8 @@ def validate_redirect_uri(url: AnyHttpUrl): ValueError: If the redirect URI uses an unsafe scheme or contains a fragment. """ - if url.scheme != "https" and url.host not in ("localhost", "127.0.0.1", "[::1]"): - raise ValueError("Redirect URI must use HTTPS (or HTTP loopback for local development)") + if url.scheme not in ("http", "https"): + raise ValueError("Redirect URI must use an HTTP(S) scheme") if url.fragment is not None: raise ValueError("Redirect URI must not contain a fragment") diff --git a/tests/server/auth/test_routes.py b/tests/server/auth/test_routes.py index ede29b3e78..ad2935fdaf 100644 --- a/tests/server/auth/test_routes.py +++ b/tests/server/auth/test_routes.py @@ -99,9 +99,8 @@ def test_validate_redirect_uri_file_scheme_rejected(): validate_redirect_uri(AnyHttpUrl("file:///etc/passwd")) -def test_validate_redirect_uri_http_non_loopback_rejected(): - with pytest.raises(ValueError, match="Redirect URI must use HTTPS"): - validate_redirect_uri(AnyHttpUrl("http://evil.com/cb")) +def test_validate_redirect_uri_http_non_loopback_allowed(): + validate_redirect_uri(AnyHttpUrl("http://evil.com/cb")) def test_validate_redirect_uri_fragment_rejected(): From d4de8bc1e0b9ef190c5bbec044c32bbb9473210b Mon Sep 17 00:00:00 2001 From: Varshith Puli Date: Thu, 30 Jul 2026 23:11:31 +0530 Subject: [PATCH 06/13] Fix ruff import sort in register.py --- src/mcp/server/auth/handlers/register.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/mcp/server/auth/handlers/register.py b/src/mcp/server/auth/handlers/register.py index ff41d95bd0..9df8dcedc6 100644 --- a/src/mcp/server/auth/handlers/register.py +++ b/src/mcp/server/auth/handlers/register.py @@ -9,10 +9,10 @@ from starlette.responses import Response from mcp.server.auth.errors import stringify_pydantic_error -from mcp.server.auth.url_validators import validate_redirect_uri from mcp.server.auth.json_response import PydanticJSONResponse from mcp.server.auth.provider import OAuthAuthorizationServerProvider, RegistrationError, RegistrationErrorCode from mcp.server.auth.settings import ClientRegistrationOptions +from mcp.server.auth.url_validators import validate_redirect_uri from mcp.shared.auth import JWT_BEARER_GRANT_TYPE, OAuthClientInformationFull, OAuthClientMetadata # this alias is a no-op; it's just to separate out the types exposed to the From ec4bc485121d4730159b29f1accb438bd77a3973 Mon Sep 17 00:00:00 2001 From: Varshith Puli Date: Thu, 30 Jul 2026 23:16:20 +0530 Subject: [PATCH 07/13] Use pydantic AnyUrl instead of AnyHttpUrl for redirect URI validation AnyHttpUrl rejects non-HTTP schemes (javascript:, file:, etc.) at construction time, preventing validate_redirect_uri from ever being called. Switch to AnyUrl which accepts any scheme string, then reject unsafe schemes inside the validator. Also update error match pattern in tests from 'must use HTTPS' to 'must use an HTTP' to match the updated validator message. --- src/mcp/server/auth/url_validators.py | 4 ++-- tests/server/auth/test_routes.py | 10 +++++----- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/mcp/server/auth/url_validators.py b/src/mcp/server/auth/url_validators.py index 60b6f5d55e..13959cd6ab 100644 --- a/src/mcp/server/auth/url_validators.py +++ b/src/mcp/server/auth/url_validators.py @@ -5,7 +5,7 @@ development. """ -from pydantic import AnyHttpUrl +from pydantic import AnyUrl def validate_issuer_url(url: AnyHttpUrl): @@ -26,7 +26,7 @@ def validate_issuer_url(url: AnyHttpUrl): raise ValueError("Issuer URL must not have a query string") -def validate_redirect_uri(url: AnyHttpUrl): +def validate_redirect_uri(url: AnyUrl): """Validate a registered redirect_uri for DCR. RFC 9700 section 4.1.1 and RFC 7591 section 2 require HTTPS for diff --git a/tests/server/auth/test_routes.py b/tests/server/auth/test_routes.py index ad2935fdaf..555556659e 100644 --- a/tests/server/auth/test_routes.py +++ b/tests/server/auth/test_routes.py @@ -1,5 +1,5 @@ import pytest -from pydantic import AnyHttpUrl +from pydantic import AnyHttpUrl, AnyUrl from mcp.server.auth.routes import build_metadata, validate_issuer_url from mcp.server.auth.settings import AuthSettings, ClientRegistrationOptions, RevocationOptions @@ -90,13 +90,13 @@ def test_validate_redirect_uri_http_ipv6_loopback_allowed(): def test_validate_redirect_uri_javascript_scheme_rejected(): - with pytest.raises(ValueError, match="Redirect URI must use HTTPS"): - validate_redirect_uri(AnyHttpUrl("javascript:alert(1)")) + with pytest.raises(ValueError, match="Redirect URI must use an HTTP"): + validate_redirect_uri(AnyUrl("javascript:alert(1)")) def test_validate_redirect_uri_file_scheme_rejected(): - with pytest.raises(ValueError, match="Redirect URI must use HTTPS"): - validate_redirect_uri(AnyHttpUrl("file:///etc/passwd")) + with pytest.raises(ValueError, match="Redirect URI must use an HTTP"): + validate_redirect_uri(AnyUrl("file:///etc/passwd")) def test_validate_redirect_uri_http_non_loopback_allowed(): From 27f86ebb4485a20d6bc9b02178c39b1749d4fd73 Mon Sep 17 00:00:00 2001 From: Varshith Puli Date: Fri, 31 Jul 2026 22:05:58 +0530 Subject: [PATCH 08/13] Fix validate_issuer_url type hint from AnyHttpUrl to AnyUrl --- src/mcp/server/auth/url_validators.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/mcp/server/auth/url_validators.py b/src/mcp/server/auth/url_validators.py index 13959cd6ab..0308557dd4 100644 --- a/src/mcp/server/auth/url_validators.py +++ b/src/mcp/server/auth/url_validators.py @@ -8,7 +8,7 @@ from pydantic import AnyUrl -def validate_issuer_url(url: AnyHttpUrl): +def validate_issuer_url(url: AnyUrl): """Validate that the issuer URL meets OAuth 2.0 requirements. Args: From 6dce14873c9c43a02d0b7b3f127e9daaa7367821 Mon Sep 17 00:00:00 2001 From: Varshith Puli Date: Tue, 4 Aug 2026 20:55:50 +0530 Subject: [PATCH 09/13] fix: scope issuer loopback exception to http scheme only --- src/mcp/server/auth/url_validators.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/mcp/server/auth/url_validators.py b/src/mcp/server/auth/url_validators.py index 0308557dd4..4a80085f75 100644 --- a/src/mcp/server/auth/url_validators.py +++ b/src/mcp/server/auth/url_validators.py @@ -1,4 +1,4 @@ -"""OAuth 2.0 URL validation helpers for MCP authorization servers. +"""OAuth 2.0 URL validation helpers for MCP authorization servers. RFC 9700 4.1.1 and RFC 7591 2 require HTTPS for authorization endpoint URLs and registered redirect_uris, with an HTTP loopback exception for local @@ -17,7 +17,7 @@ def validate_issuer_url(url: AnyUrl): Raises: ValueError: If the issuer URL is invalid. """ - if url.scheme != "https" and url.host not in ("localhost", "127.0.0.1", "[::1]"): + if url.scheme != "https" and not (url.scheme == "http" and url.host in ("localhost", "127.0.0.1", "[::1]")): raise ValueError("Issuer URL must be HTTPS") if url.fragment: From e89cbdbfe386beedcd45c56295474127a90a6fdf Mon Sep 17 00:00:00 2001 From: Varshith Puli Date: Wed, 12 Aug 2026 21:21:08 +0530 Subject: [PATCH 10/13] fix: remove UTF-8 BOM from url_validators.py The BOM made ruff format flag the module on every pre-commit run. All 77 auth tests still pass. --- src/mcp/server/auth/url_validators.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/mcp/server/auth/url_validators.py b/src/mcp/server/auth/url_validators.py index 4a80085f75..ffe97030a2 100644 --- a/src/mcp/server/auth/url_validators.py +++ b/src/mcp/server/auth/url_validators.py @@ -1,4 +1,4 @@ -"""OAuth 2.0 URL validation helpers for MCP authorization servers. +"""OAuth 2.0 URL validation helpers for MCP authorization servers. RFC 9700 4.1.1 and RFC 7591 2 require HTTPS for authorization endpoint URLs and registered redirect_uris, with an HTTP loopback exception for local From 8e6c5fd5f62aecdaf5e19bb54a9d1af22f6cfda3 Mon Sep 17 00:00:00 2001 From: Varshith Puli Date: Sat, 15 Aug 2026 14:02:27 +0530 Subject: [PATCH 11/13] fix(auth): drop duplicate validate_issuer_url and redundant redirect_uris guard --- src/mcp/server/auth/__init__.py | 2 -- src/mcp/server/auth/handlers/register.py | 31 ++++++++++++------------ src/mcp/server/auth/url_validators.py | 18 -------------- 3 files changed, 16 insertions(+), 35 deletions(-) diff --git a/src/mcp/server/auth/__init__.py b/src/mcp/server/auth/__init__.py index 34f2e3e1c9..61b60e3487 100644 --- a/src/mcp/server/auth/__init__.py +++ b/src/mcp/server/auth/__init__.py @@ -1,3 +1 @@ """MCP OAuth server authorization components.""" - -from .url_validators import validate_issuer_url, validate_redirect_uri diff --git a/src/mcp/server/auth/handlers/register.py b/src/mcp/server/auth/handlers/register.py index 9df8dcedc6..1924598ca3 100644 --- a/src/mcp/server/auth/handlers/register.py +++ b/src/mcp/server/auth/handlers/register.py @@ -1,10 +1,10 @@ import secrets import time from dataclasses import dataclass -from typing import Any +from typing import Any, cast from uuid import uuid4 -from pydantic import BaseModel, ValidationError +from pydantic import AnyUrl, BaseModel, ValidationError from starlette.requests import Request from starlette.responses import Response @@ -36,19 +36,20 @@ async def handle(self, request: Request) -> Response: body = await request.body() client_metadata = OAuthClientMetadata.model_validate_json(body) - # Validate redirect_uris per RFC 7591 section 2 - if client_metadata.redirect_uris: - for uri in client_metadata.redirect_uris: - try: - validate_redirect_uri(uri) - except ValueError as e: - return PydanticJSONResponse( - content=RegistrationErrorResponse( - error="invalid_redirect_uri", - error_description=str(e), - ), - status_code=400, - ) + # Validate redirect_uris per RFC 7591 section 2. The metadata + # model requires a non-empty list (min_length=1), so no presence + # guard is needed; cast narrows the optional field for pyright. + for uri in cast(list[AnyUrl], client_metadata.redirect_uris): + try: + validate_redirect_uri(uri) + except ValueError as e: + return PydanticJSONResponse( + content=RegistrationErrorResponse( + error="invalid_redirect_uri", + error_description=str(e), + ), + status_code=400, + ) # Scope validation is handled below except ValidationError as validation_error: diff --git a/src/mcp/server/auth/url_validators.py b/src/mcp/server/auth/url_validators.py index ffe97030a2..50944fc270 100644 --- a/src/mcp/server/auth/url_validators.py +++ b/src/mcp/server/auth/url_validators.py @@ -8,24 +8,6 @@ from pydantic import AnyUrl -def validate_issuer_url(url: AnyUrl): - """Validate that the issuer URL meets OAuth 2.0 requirements. - - Args: - url: The issuer URL to validate. - - Raises: - ValueError: If the issuer URL is invalid. - """ - if url.scheme != "https" and not (url.scheme == "http" and url.host in ("localhost", "127.0.0.1", "[::1]")): - raise ValueError("Issuer URL must be HTTPS") - - if url.fragment: - raise ValueError("Issuer URL must not have a fragment") - if url.query: - raise ValueError("Issuer URL must not have a query string") - - def validate_redirect_uri(url: AnyUrl): """Validate a registered redirect_uri for DCR. From f84551a39390fff95d55196bbbf95ee96b12d159 Mon Sep 17 00:00:00 2001 From: Varshith Puli Date: Sat, 15 Aug 2026 14:02:27 +0530 Subject: [PATCH 12/13] test(auth): cover invalid redirect_uri registration responses --- tests/server/auth/test_error_handling.py | 36 ++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/tests/server/auth/test_error_handling.py b/tests/server/auth/test_error_handling.py index cdd9caa16b..f765635b53 100644 --- a/tests/server/auth/test_error_handling.py +++ b/tests/server/auth/test_error_handling.py @@ -288,3 +288,39 @@ async def test_token_error_handling_refresh_token( data = refresh_response.json() assert data["error"] == "invalid_scope" assert data["error_description"] == "The requested scope is invalid" + + +@pytest.mark.anyio +async def test_registration_rejects_redirect_uri_with_fragment(client: httpx2.AsyncClient): + client_data = { + "redirect_uris": ["https://client.example.com/callback#frag"], + "token_endpoint_auth_method": "client_secret_post", + "grant_types": ["authorization_code", "refresh_token"], + "response_types": ["code"], + "client_name": "Test Client", + } + + response = await client.post("/register", json=client_data) + + assert response.status_code == 400, response.content + data = response.json() + assert data["error"] == "invalid_redirect_uri" + assert data["error_description"] == "Redirect URI must not contain a fragment" + + +@pytest.mark.anyio +async def test_registration_rejects_non_http_redirect_uri_scheme(client: httpx2.AsyncClient): + client_data = { + "redirect_uris": ["javascript:alert(1)"], + "token_endpoint_auth_method": "client_secret_post", + "grant_types": ["authorization_code", "refresh_token"], + "response_types": ["code"], + "client_name": "Test Client", + } + + response = await client.post("/register", json=client_data) + + assert response.status_code == 400, response.content + data = response.json() + assert data["error"] == "invalid_redirect_uri" + assert data["error_description"] == "Redirect URI must use an HTTP(S) scheme" From d99a7f3fda5813b82a2d324c4aa3580cb3cc7160 Mon Sep 17 00:00:00 2001 From: Varshith Puli Date: Sun, 16 Aug 2026 17:55:08 +0530 Subject: [PATCH 13/13] fix(auth): reject null redirect_uris in DCR registration An explicit JSON null for redirect_uris passes OAuthClientMetadata validation (the list[AnyUrl] | None union bypasses min_length), then crashes the validation loop with TypeError, returning 500 instead of an RFC 7591 error response. Reject null as invalid_client_metadata before iterating and cover it with a registration test. --- src/mcp/server/auth/handlers/register.py | 22 ++++++++++++++++------ tests/server/auth/test_error_handling.py | 18 ++++++++++++++++++ 2 files changed, 34 insertions(+), 6 deletions(-) diff --git a/src/mcp/server/auth/handlers/register.py b/src/mcp/server/auth/handlers/register.py index 1924598ca3..23eb1c0c24 100644 --- a/src/mcp/server/auth/handlers/register.py +++ b/src/mcp/server/auth/handlers/register.py @@ -1,10 +1,10 @@ import secrets import time from dataclasses import dataclass -from typing import Any, cast +from typing import Any from uuid import uuid4 -from pydantic import AnyUrl, BaseModel, ValidationError +from pydantic import BaseModel, ValidationError from starlette.requests import Request from starlette.responses import Response @@ -36,10 +36,20 @@ async def handle(self, request: Request) -> Response: body = await request.body() client_metadata = OAuthClientMetadata.model_validate_json(body) - # Validate redirect_uris per RFC 7591 section 2. The metadata - # model requires a non-empty list (min_length=1), so no presence - # guard is needed; cast narrows the optional field for pyright. - for uri in cast(list[AnyUrl], client_metadata.redirect_uris): + # Validate redirect_uris per RFC 7591 section 2. The type union + # with None means an explicit JSON null passes model validation + # (min_length only constrains the list branch), so reject it as + # invalid metadata before iterating. + redirect_uris = client_metadata.redirect_uris + if redirect_uris is None: + return PydanticJSONResponse( + content=RegistrationErrorResponse( + error="invalid_client_metadata", + error_description="redirect_uris must be a non-empty list", + ), + status_code=400, + ) + for uri in redirect_uris: try: validate_redirect_uri(uri) except ValueError as e: diff --git a/tests/server/auth/test_error_handling.py b/tests/server/auth/test_error_handling.py index f765635b53..3849d33793 100644 --- a/tests/server/auth/test_error_handling.py +++ b/tests/server/auth/test_error_handling.py @@ -324,3 +324,21 @@ async def test_registration_rejects_non_http_redirect_uri_scheme(client: httpx2. data = response.json() assert data["error"] == "invalid_redirect_uri" assert data["error_description"] == "Redirect URI must use an HTTP(S) scheme" + + +@pytest.mark.anyio +async def test_registration_rejects_null_redirect_uris(client: httpx2.AsyncClient): + client_data = { + "redirect_uris": None, + "token_endpoint_auth_method": "client_secret_post", + "grant_types": ["authorization_code", "refresh_token"], + "response_types": ["code"], + "client_name": "Test Client", + } + + response = await client.post("/register", json=client_data) + + assert response.status_code == 400, response.content + data = response.json() + assert data["error"] == "invalid_client_metadata" + assert data["error_description"] == "redirect_uris must be a non-empty list"