diff --git a/src/mcp/shared/auth_utils.py b/src/mcp/shared/auth_utils.py index 3ba880f40..80563a513 100644 --- a/src/mcp/shared/auth_utils.py +++ b/src/mcp/shared/auth_utils.py @@ -6,6 +6,19 @@ from pydantic import AnyUrl, HttpUrl +def _lowercase_host(netloc: str) -> str: + userinfo, separator, hostport = netloc.rpartition("@") + prefix = f"{userinfo}{separator}" if separator else "" + + if hostport.startswith("["): + end = hostport.find("]") + if end != -1: + return f"{prefix}{hostport[: end + 1].lower()}{hostport[end + 1 :]}" + + host, separator, port = hostport.partition(":") + return f"{prefix}{host.lower()}{separator}{port}" + + def resource_url_from_server_url(url: str | HttpUrl | AnyUrl) -> str: """Convert server URL to canonical resource URL per RFC 8707. @@ -23,7 +36,13 @@ def resource_url_from_server_url(url: str | HttpUrl | AnyUrl) -> str: # Parse the URL and remove fragment, create canonical form parsed = urlsplit(url_str) - canonical = urlunsplit(parsed._replace(scheme=parsed.scheme.lower(), netloc=parsed.netloc.lower(), fragment="")) + canonical = urlunsplit( + parsed._replace( + scheme=parsed.scheme.lower(), + netloc=_lowercase_host(parsed.netloc), + fragment="", + ) + ) return canonical diff --git a/tests/shared/test_auth_utils.py b/tests/shared/test_auth_utils.py index 5ae0e22b0..c31aaae1a 100644 --- a/tests/shared/test_auth_utils.py +++ b/tests/shared/test_auth_utils.py @@ -40,6 +40,14 @@ def test_resource_url_from_server_url_lowercase_scheme_and_host(): assert resource_url_from_server_url("Http://Example.Com:8080/") == "http://example.com:8080/" +def test_resource_url_from_server_url_preserves_userinfo_case(): + """Only the scheme and host are canonicalized; userinfo is preserved byte-for-byte.""" + assert ( + resource_url_from_server_url("HTTPS://User:PaSs@EXAMPLE.COM/path#fragment") + == "https://User:PaSs@example.com/path" + ) + + def test_resource_url_from_server_url_handles_pydantic_urls(): """Should handle Pydantic URL types.""" url = HttpUrl("https://example.com/path")