Skip to content

Commit 658c5b9

Browse files
fix(auth): coerce empty-string optional URL fields to None in OAuthClientMetadata
1 parent 6524782 commit 658c5b9

File tree

2 files changed

+92
-1
lines changed

2 files changed

+92
-1
lines changed

src/mcp/shared/auth.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,24 @@ class OAuthClientMetadata(BaseModel):
7171
software_id: str | None = None
7272
software_version: str | None = None
7373

74+
@field_validator(
75+
"client_uri",
76+
"logo_uri",
77+
"tos_uri",
78+
"policy_uri",
79+
"jwks_uri",
80+
mode="before",
81+
)
82+
@classmethod
83+
def _empty_string_optional_url_to_none(cls, v: object) -> object:
84+
# RFC 7591 §2 marks these URL fields OPTIONAL. Some authorization servers
85+
# echo omitted metadata back as "" instead of dropping the keys, which
86+
# AnyHttpUrl would otherwise reject — throwing away an otherwise valid
87+
# registration response. Treat "" as absent.
88+
if v == "":
89+
return None
90+
return v
91+
7492
def validate_scope(self, requested_scope: str | None) -> list[str] | None:
7593
if requested_scope is None:
7694
return None

tests/client/test_auth.py

Lines changed: 74 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
import httpx
1111
import pytest
1212
from inline_snapshot import Is, snapshot
13-
from pydantic import AnyHttpUrl, AnyUrl
13+
from pydantic import AnyHttpUrl, AnyUrl, ValidationError
1414

1515
from mcp.client.auth import OAuthClientProvider, PKCEParameters
1616
from mcp.client.auth.exceptions import OAuthFlowError
@@ -857,6 +857,79 @@ def text(self):
857857
assert "Registration failed: 400" in str(exc_info.value)
858858

859859

860+
class TestOAuthClientMetadataEmptyUrlCoercion:
861+
"""RFC 7591 §2 marks client_uri/logo_uri/tos_uri/policy_uri/jwks_uri as OPTIONAL.
862+
Some authorization servers echo the client's omitted metadata back as ""
863+
instead of dropping the keys; without coercion, AnyHttpUrl rejects "" and
864+
the whole registration response is thrown away even though the server
865+
returned a valid client_id."""
866+
867+
@pytest.mark.parametrize(
868+
"empty_field",
869+
["client_uri", "logo_uri", "tos_uri", "policy_uri", "jwks_uri"],
870+
)
871+
def test_optional_url_empty_string_coerced_to_none(self, empty_field: str):
872+
data = {
873+
"redirect_uris": ["https://example.com/callback"],
874+
empty_field: "",
875+
}
876+
metadata = OAuthClientMetadata.model_validate(data)
877+
assert getattr(metadata, empty_field) is None
878+
879+
def test_all_optional_urls_empty_together(self):
880+
data = {
881+
"redirect_uris": ["https://example.com/callback"],
882+
"client_uri": "",
883+
"logo_uri": "",
884+
"tos_uri": "",
885+
"policy_uri": "",
886+
"jwks_uri": "",
887+
}
888+
metadata = OAuthClientMetadata.model_validate(data)
889+
assert metadata.client_uri is None
890+
assert metadata.logo_uri is None
891+
assert metadata.tos_uri is None
892+
assert metadata.policy_uri is None
893+
assert metadata.jwks_uri is None
894+
895+
def test_valid_url_passes_through_unchanged(self):
896+
data = {
897+
"redirect_uris": ["https://example.com/callback"],
898+
"client_uri": "https://udemy.com/",
899+
}
900+
metadata = OAuthClientMetadata.model_validate(data)
901+
assert str(metadata.client_uri) == "https://udemy.com/"
902+
903+
def test_information_full_inherits_coercion(self):
904+
"""OAuthClientInformationFull subclasses OAuthClientMetadata, so the
905+
same coercion applies to DCR responses parsed via the full model."""
906+
data = {
907+
"client_id": "abc123",
908+
"redirect_uris": ["https://example.com/callback"],
909+
"client_uri": "",
910+
"logo_uri": "",
911+
"tos_uri": "",
912+
"policy_uri": "",
913+
"jwks_uri": "",
914+
}
915+
info = OAuthClientInformationFull.model_validate(data)
916+
assert info.client_id == "abc123"
917+
assert info.client_uri is None
918+
assert info.logo_uri is None
919+
assert info.tos_uri is None
920+
assert info.policy_uri is None
921+
assert info.jwks_uri is None
922+
923+
def test_invalid_non_empty_url_still_rejected(self):
924+
"""Coercion must only touch empty strings — garbage URLs still raise."""
925+
data = {
926+
"redirect_uris": ["https://example.com/callback"],
927+
"client_uri": "not a url",
928+
}
929+
with pytest.raises(ValidationError):
930+
OAuthClientMetadata.model_validate(data)
931+
932+
860933
class TestCreateClientRegistrationRequest:
861934
"""Test client registration request creation."""
862935

0 commit comments

Comments
 (0)