Skip to content

Commit 323c56f

Browse files
pcarletonclaude
andcommitted
Keep the RFC 8707 resource byte-exact on pydantic < 2.12
The 1.x line still allows pydantic 2.11 (Python < 3.14), where the url_preserve_empty_path config from #2925 is silently ignored and a path-less PRM resource still renders with a trailing slash. Record the wire string on ProtectedResourceMetadata (resource_str) and use it for the resource parameter, so the client echoes the server's identifier verbatim on every supported pydantic version. Also spell the config as a cast dict so the 2.11 type stubs accept it, and make the cherry-picked issuer assertion version-tolerant. Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 818f0f2 commit 323c56f

3 files changed

Lines changed: 71 additions & 10 deletions

File tree

src/mcp/client/auth/oauth2.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -155,7 +155,7 @@ def get_resource_url(self) -> str:
155155

156156
# If PRM provides a resource that's a valid parent, use it
157157
if self.protected_resource_metadata and self.protected_resource_metadata.resource:
158-
prm_resource = str(self.protected_resource_metadata.resource)
158+
prm_resource = self.protected_resource_metadata.resource_str
159159
if check_resource_allowed(requested_resource=resource, configured_resource=prm_resource):
160160
resource = prm_resource
161161

@@ -280,7 +280,7 @@ def __init__(
280280

281281
async def _validate_resource_match(self, prm: ProtectedResourceMetadata) -> None:
282282
"""Validate that PRM resource matches the server URL per RFC 8707."""
283-
prm_resource = str(prm.resource) if prm.resource else None
283+
prm_resource = prm.resource_str if prm.resource else None
284284
if not prm_resource:
285285
return # pragma: no cover
286286
default_resource = resource_url_from_server_url(self.context.server_url)

src/mcp/shared/auth.py

Lines changed: 44 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,23 @@
1-
from typing import Any, Literal
2-
3-
from pydantic import AnyHttpUrl, AnyUrl, BaseModel, ConfigDict, Field, field_validator
1+
from typing import Any, Literal, cast
2+
3+
from pydantic import (
4+
AnyHttpUrl,
5+
AnyUrl,
6+
BaseModel,
7+
ConfigDict,
8+
Field,
9+
PrivateAttr,
10+
ValidatorFunctionWrapHandler,
11+
field_validator,
12+
model_validator,
13+
)
14+
15+
# `url_preserve_empty_path` (pydantic >= 2.12) keeps a path-less URL from gaining a trailing slash when
16+
# parsed from the wire, so issuer/resource identifiers round-trip as transmitted (RFC 3986 §6.2.1 simple
17+
# string comparison). Older pydantic ignores unknown config keys at runtime; the cast keeps the 2.11 type
18+
# stubs (which do not know the key) quiet. See `ProtectedResourceMetadata.resource_str` for the
19+
# version-independent path used for the RFC 8707 `resource` parameter.
20+
_PRESERVE_EMPTY_PATH = cast(ConfigDict, {"url_preserve_empty_path": True})
421

522

623
class OAuthToken(BaseModel):
@@ -41,7 +58,7 @@ class OAuthClientMetadata(BaseModel):
4158
for the full specification.
4259
"""
4360

44-
model_config = ConfigDict(url_preserve_empty_path=True)
61+
model_config = _PRESERVE_EMPTY_PATH
4562

4663
redirect_uris: list[AnyUrl] | None = Field(..., min_length=1)
4764
# supported auth methods for the token endpoint
@@ -134,7 +151,7 @@ class OAuthMetadata(BaseModel):
134151
See https://datatracker.ietf.org/doc/html/rfc8414#section-2
135152
"""
136153

137-
model_config = ConfigDict(url_preserve_empty_path=True)
154+
model_config = _PRESERVE_EMPTY_PATH
138155

139156
issuer: AnyHttpUrl
140157
authorization_endpoint: AnyHttpUrl
@@ -166,10 +183,31 @@ class ProtectedResourceMetadata(BaseModel):
166183
See https://datatracker.ietf.org/doc/html/rfc9728#section-2
167184
"""
168185

169-
model_config = ConfigDict(url_preserve_empty_path=True)
186+
model_config = _PRESERVE_EMPTY_PATH
170187

171188
resource: AnyHttpUrl
172189
authorization_servers: list[AnyHttpUrl] = Field(..., min_length=1)
190+
# The `resource` value exactly as received. `url_preserve_empty_path` only takes effect on
191+
# pydantic >= 2.12; on older pydantic a path-less URL still renders with a trailing slash, which
192+
# breaks the byte-exact RFC 8707 `resource` parameter. Kept alongside the parsed URL so callers
193+
# can echo the server's identifier verbatim regardless of pydantic version.
194+
_resource_raw: str | None = PrivateAttr(default=None)
195+
196+
@model_validator(mode="wrap")
197+
@classmethod
198+
def _capture_raw_resource(cls, data: Any, handler: ValidatorFunctionWrapHandler) -> "ProtectedResourceMetadata":
199+
raw: Any = cast(dict[str, Any], data).get("resource") if isinstance(data, dict) else None
200+
model = cast("ProtectedResourceMetadata", handler(data))
201+
if isinstance(raw, str):
202+
model._resource_raw = raw
203+
return model
204+
205+
@property
206+
def resource_str(self) -> str:
207+
"""The resource identifier as a string, exactly as the server published it when parsed from
208+
JSON/dict input (RFC 8707 requires clients to send it byte-for-byte); otherwise the rendered URL."""
209+
return self._resource_raw if self._resource_raw is not None else str(self.resource)
210+
173211
jwks_uri: AnyHttpUrl | None = None
174212
scopes_supported: list[str] | None = None
175213
bearer_methods_supported: list[str] | None = Field(default=["header"]) # MCP only supports header method

tests/client/test_auth.py

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -522,10 +522,11 @@ async def test_handle_metadata_response_success(self, oauth_provider: OAuthClien
522522
}"""
523523
response = httpx.Response(200, content=content)
524524

525-
# Should set metadata; the empty path is preserved (no trailing slash added)
525+
# Should set metadata. On pydantic >= 2.12 the empty path is preserved (no trailing slash);
526+
# older pydantic ignores `url_preserve_empty_path` and still normalises to ".../".
526527
await oauth_provider._handle_oauth_metadata_response(response)
527528
assert oauth_provider.context.oauth_metadata is not None
528-
assert str(oauth_provider.context.oauth_metadata.issuer) == "https://auth.example.com"
529+
assert str(oauth_provider.context.oauth_metadata.issuer).rstrip("/") == "https://auth.example.com"
529530

530531
@pytest.mark.anyio
531532
async def test_prioritize_www_auth_scope_over_prm(
@@ -2212,6 +2213,28 @@ async def test_get_resource_url_falls_back_when_prm_mismatches(
22122213
assert provider.context.get_resource_url() == "https://api.example.com/v1/mcp"
22132214

22142215

2216+
@pytest.mark.anyio
2217+
async def test_get_resource_url_echoes_pathless_prm_resource_verbatim(
2218+
client_metadata: OAuthClientMetadata, mock_storage: MockTokenStorage
2219+
) -> None:
2220+
"""RFC 8707: the `resource` parameter is the PRM `resource` byte-for-byte. A path-less identifier
2221+
parsed from the wire must not gain a trailing slash, on any supported pydantic version."""
2222+
provider = OAuthClientProvider(
2223+
server_url="https://api.example.com/mcp",
2224+
client_metadata=client_metadata,
2225+
storage=mock_storage,
2226+
)
2227+
provider._initialized = True
2228+
2229+
prm = ProtectedResourceMetadata.model_validate_json(
2230+
'{"resource": "https://api.example.com", "authorization_servers": ["https://auth.example.com"]}'
2231+
)
2232+
assert prm.resource_str == "https://api.example.com"
2233+
provider.context.protected_resource_metadata = prm
2234+
2235+
assert provider.context.get_resource_url() == "https://api.example.com"
2236+
2237+
22152238
def _prepare_full_flow(provider: OAuthClientProvider, client_info: OAuthClientInformationFull | None) -> list[str]:
22162239
"""Reset `provider` for a full flow with `client_info` as the stored registration, and wire a
22172240
redirect/callback pair that echoes the `state` of the last authorization URL it was sent to.

0 commit comments

Comments
 (0)