From 3b6ae0bb24482f2533fa8d260d94762d6b17f158 Mon Sep 17 00:00:00 2001 From: Varshith Puli Date: Tue, 28 Jul 2026 22:09:17 +0530 Subject: [PATCH 01/12] 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 ac0e1bf6302856e92322e3a465b5ed5c0c4d7df7 Mon Sep 17 00:00:00 2001 From: Varshith Puli Date: Wed, 29 Jul 2026 20:37:13 +0530 Subject: [PATCH 02/12] 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 78c474b860fb302a0758febc6f3b0deafca554b5 Mon Sep 17 00:00:00 2001 From: Varshith Puli Date: Wed, 29 Jul 2026 20:42:45 +0530 Subject: [PATCH 03/12] 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 5e0ddf4e56224f1ee39fa9bfb6c49dc52b689ebd Mon Sep 17 00:00:00 2001 From: Varshith Puli Date: Wed, 29 Jul 2026 22:02:35 +0530 Subject: [PATCH 04/12] 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 1dc8099111779802a39f8ea10c585d84f631318a Mon Sep 17 00:00:00 2001 From: Varshith Puli Date: Thu, 30 Jul 2026 00:00:27 +0530 Subject: [PATCH 05/12] 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 36ffbd1eea46f16cace8831d8a0c9617d3cefe9c Mon Sep 17 00:00:00 2001 From: Varshith Puli Date: Thu, 30 Jul 2026 23:11:31 +0530 Subject: [PATCH 06/12] 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 8f0f5a243e311a7da951b30430a12cf757d2992a Mon Sep 17 00:00:00 2001 From: Varshith Puli Date: Thu, 30 Jul 2026 23:16:20 +0530 Subject: [PATCH 07/12] 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 f66c3432d0ac62881cee5fcb5427f05825bb97c0 Mon Sep 17 00:00:00 2001 From: Varshith Puli Date: Fri, 31 Jul 2026 22:05:58 +0530 Subject: [PATCH 08/12] 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 e626b49327a90a56f393df58f757dfba4b1a9ca2 Mon Sep 17 00:00:00 2001 From: Varshith Puli Date: Tue, 4 Aug 2026 20:55:50 +0530 Subject: [PATCH 09/12] 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 9f80e4e65bfc80ab80083509cce4c5ddc109a711 Mon Sep 17 00:00:00 2001 From: Varshith Puli Date: Wed, 12 Aug 2026 21:21:08 +0530 Subject: [PATCH 10/12] 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 2fbb687d511e8b1c2fba0831b452df7495007d44 Mon Sep 17 00:00:00 2001 From: Varshith Puli Date: Sat, 15 Aug 2026 14:02:27 +0530 Subject: [PATCH 11/12] 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 d6b3e4c7e2dfda521360f882367a4c2351207e1d Mon Sep 17 00:00:00 2001 From: Varshith Puli Date: Sat, 15 Aug 2026 14:02:27 +0530 Subject: [PATCH 12/12] 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"