Skip to content

Commit 97d02db

Browse files
fix: pass conformance auth scenarios, add RFC 8707 resource validation
The conformance test suite was broken by @modelcontextprotocol/conformance@0.1.13 introducing new auth scenarios that require: 1. Pre-registered client credentials from MCP_CONFORMANCE_CONTEXT 2. RFC 8707 resource validation (PRM resource must match server URL) SDK changes: - Add _validate_resource_match() to OAuthClientProvider that validates the Protected Resource Metadata resource field matches the server URL before proceeding with the auth flow - Add validate_resource_url callback parameter for custom validation Conformance client changes: - Pre-load client credentials from MCP_CONFORMANCE_CONTEXT into token storage when available, allowing the existing flow to skip DCR when pre-registered credentials are present CI: bump conformance package from 0.1.10 to 0.1.13
1 parent 1a943ad commit 97d02db

File tree

4 files changed

+185
-4
lines changed

4 files changed

+185
-4
lines changed

.github/actions/conformance/client.py

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -275,6 +275,27 @@ async def run_client_credentials_basic(server_url: str) -> None:
275275
async def run_auth_code_client(server_url: str) -> None:
276276
"""Authorization code flow (default for auth/* scenarios)."""
277277
callback_handler = ConformanceOAuthCallbackHandler()
278+
storage = InMemoryTokenStorage()
279+
280+
# Check for pre-registered client credentials from context
281+
context_json = os.environ.get("MCP_CONFORMANCE_CONTEXT")
282+
if context_json:
283+
try:
284+
context = json.loads(context_json)
285+
client_id = context.get("client_id")
286+
client_secret = context.get("client_secret")
287+
if client_id:
288+
await storage.set_client_info(
289+
OAuthClientInformationFull(
290+
client_id=client_id,
291+
client_secret=client_secret,
292+
redirect_uris=[AnyUrl("http://localhost:3000/callback")],
293+
token_endpoint_auth_method="client_secret_basic" if client_secret else "none",
294+
)
295+
)
296+
logger.debug(f"Pre-loaded client credentials: client_id={client_id}")
297+
except json.JSONDecodeError:
298+
pass
278299

279300
oauth_auth = OAuthClientProvider(
280301
server_url=server_url,
@@ -284,7 +305,7 @@ async def run_auth_code_client(server_url: str) -> None:
284305
grant_types=["authorization_code", "refresh_token"],
285306
response_types=["code"],
286307
),
287-
storage=InMemoryTokenStorage(),
308+
storage=storage,
288309
redirect_handler=callback_handler.handle_redirect,
289310
callback_handler=callback_handler.handle_callback,
290311
client_metadata_url="https://conformance-test.local/client-metadata.json",

.github/workflows/conformance.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,4 +42,4 @@ jobs:
4242
with:
4343
node-version: 24
4444
- run: uv sync --frozen --all-extras --package mcp
45-
- run: npx @modelcontextprotocol/conformance@0.1.10 client --command 'uv run --frozen python .github/actions/conformance/client.py' --suite all
45+
- run: npx @modelcontextprotocol/conformance@0.1.13 client --command 'uv run --frozen python .github/actions/conformance/client.py' --suite all

src/mcp/client/auth/oauth2.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,6 +229,7 @@ def __init__(
229229
callback_handler: Callable[[], Awaitable[tuple[str, str | None]]] | None = None,
230230
timeout: float = 300.0,
231231
client_metadata_url: str | None = None,
232+
validate_resource_url: Callable[[str, str | None], Awaitable[None]] | None = None,
232233
):
233234
"""Initialize OAuth2 authentication.
234235
@@ -243,6 +244,10 @@ def __init__(
243244
advertises client_id_metadata_document_supported=true, this URL will be
244245
used as the client_id instead of performing dynamic client registration.
245246
Must be a valid HTTPS URL with a non-root pathname.
247+
validate_resource_url: Optional callback to override resource URL validation.
248+
Called with (server_url, prm_resource) where prm_resource is the resource
249+
from Protected Resource Metadata (or None if not present). If not provided,
250+
default validation rejects mismatched resources per RFC 8707.
246251
247252
Raises:
248253
ValueError: If client_metadata_url is provided but not a valid HTTPS URL
@@ -263,6 +268,7 @@ def __init__(
263268
timeout=timeout,
264269
client_metadata_url=client_metadata_url,
265270
)
271+
self._validate_resource_url_callback = validate_resource_url
266272
self._initialized = False
267273

268274
async def _handle_protected_resource_response(self, response: httpx.Response) -> bool:
@@ -476,6 +482,26 @@ async def _handle_oauth_metadata_response(self, response: httpx.Response) -> Non
476482
metadata = OAuthMetadata.model_validate_json(content)
477483
self.context.oauth_metadata = metadata
478484

485+
async def _validate_resource_match(self, prm: ProtectedResourceMetadata) -> None:
486+
"""Validate that PRM resource matches the server URL per RFC 8707."""
487+
prm_resource = str(prm.resource) if prm.resource else None
488+
489+
if self._validate_resource_url_callback is not None:
490+
await self._validate_resource_url_callback(self.context.server_url, prm_resource)
491+
return
492+
493+
if not prm_resource:
494+
return # pragma: no cover
495+
default_resource = resource_url_from_server_url(self.context.server_url)
496+
# Normalize: Pydantic AnyHttpUrl adds trailing slash to root URLs
497+
# (e.g. "https://example.com/") while resource_url_from_server_url may not.
498+
if not default_resource.endswith("/"):
499+
default_resource += "/"
500+
if not prm_resource.endswith("/"):
501+
prm_resource += "/"
502+
if not check_resource_allowed(requested_resource=default_resource, configured_resource=prm_resource):
503+
raise OAuthFlowError(f"Protected resource {prm_resource} does not match expected {default_resource}")
504+
479505
async def async_auth_flow(self, request: httpx.Request) -> AsyncGenerator[httpx.Request, httpx.Response]:
480506
"""HTTPX auth flow integration."""
481507
async with self.context.lock:
@@ -517,6 +543,8 @@ async def async_auth_flow(self, request: httpx.Request) -> AsyncGenerator[httpx.
517543

518544
prm = await handle_protected_resource_response(discovery_response)
519545
if prm:
546+
# Validate PRM resource matches server URL (RFC 8707)
547+
await self._validate_resource_match(prm)
520548
self.context.protected_resource_metadata = prm
521549

522550
# todo: try all authorization_servers to find the OASM

tests/client/test_auth.py

Lines changed: 134 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from pydantic import AnyHttpUrl, AnyUrl
1212

1313
from mcp.client.auth import OAuthClientProvider, PKCEParameters
14+
from mcp.client.auth.exceptions import OAuthFlowError
1415
from mcp.client.auth.utils import (
1516
build_oauth_authorization_server_metadata_discovery_urls,
1617
build_protected_resource_metadata_discovery_urls,
@@ -818,6 +819,137 @@ async def test_resource_param_included_with_protected_resource_metadata(self, oa
818819
assert "resource=" in content
819820

820821

822+
class TestResourceValidation:
823+
"""Test PRM resource validation in OAuthClientProvider."""
824+
825+
@pytest.mark.anyio
826+
async def test_rejects_mismatched_resource(
827+
self, client_metadata: OAuthClientMetadata, mock_storage: MockTokenStorage
828+
) -> None:
829+
"""Client must reject PRM resource that doesn't match server URL."""
830+
provider = OAuthClientProvider(
831+
server_url="https://api.example.com/v1/mcp",
832+
client_metadata=client_metadata,
833+
storage=mock_storage,
834+
)
835+
provider._initialized = True
836+
837+
prm = ProtectedResourceMetadata(
838+
resource=AnyHttpUrl("https://evil.example.com/mcp"),
839+
authorization_servers=[AnyHttpUrl("https://auth.example.com")],
840+
)
841+
with pytest.raises(OAuthFlowError, match="does not match expected"):
842+
await provider._validate_resource_match(prm)
843+
844+
@pytest.mark.anyio
845+
async def test_accepts_matching_resource(
846+
self, client_metadata: OAuthClientMetadata, mock_storage: MockTokenStorage
847+
) -> None:
848+
"""Client must accept PRM resource that matches server URL."""
849+
provider = OAuthClientProvider(
850+
server_url="https://api.example.com/v1/mcp",
851+
client_metadata=client_metadata,
852+
storage=mock_storage,
853+
)
854+
provider._initialized = True
855+
856+
prm = ProtectedResourceMetadata(
857+
resource=AnyHttpUrl("https://api.example.com/v1/mcp"),
858+
authorization_servers=[AnyHttpUrl("https://auth.example.com")],
859+
)
860+
# Should not raise
861+
await provider._validate_resource_match(prm)
862+
863+
@pytest.mark.anyio
864+
async def test_custom_validate_resource_url_callback(
865+
self, client_metadata: OAuthClientMetadata, mock_storage: MockTokenStorage
866+
) -> None:
867+
"""Custom callback overrides default validation."""
868+
callback_called_with: list[tuple[str, str | None]] = []
869+
870+
async def custom_validate(server_url: str, prm_resource: str | None) -> None:
871+
callback_called_with.append((server_url, prm_resource))
872+
873+
provider = OAuthClientProvider(
874+
server_url="https://api.example.com/v1/mcp",
875+
client_metadata=client_metadata,
876+
storage=mock_storage,
877+
validate_resource_url=custom_validate,
878+
)
879+
provider._initialized = True
880+
881+
# This would normally fail default validation (different origin),
882+
# but custom callback accepts it
883+
prm = ProtectedResourceMetadata(
884+
resource=AnyHttpUrl("https://evil.example.com/mcp"),
885+
authorization_servers=[AnyHttpUrl("https://auth.example.com")],
886+
)
887+
await provider._validate_resource_match(prm)
888+
assert len(callback_called_with) == 1
889+
assert callback_called_with[0][0] == "https://api.example.com/v1/mcp"
890+
assert callback_called_with[0][1] == "https://evil.example.com/mcp"
891+
892+
@pytest.mark.anyio
893+
async def test_accepts_root_url_with_trailing_slash(
894+
self, client_metadata: OAuthClientMetadata, mock_storage: MockTokenStorage
895+
) -> None:
896+
"""Root URLs with trailing slash normalization should match."""
897+
provider = OAuthClientProvider(
898+
server_url="https://api.example.com",
899+
client_metadata=client_metadata,
900+
storage=mock_storage,
901+
)
902+
provider._initialized = True
903+
904+
prm = ProtectedResourceMetadata(
905+
resource=AnyHttpUrl("https://api.example.com/"),
906+
authorization_servers=[AnyHttpUrl("https://auth.example.com")],
907+
)
908+
# Should not raise despite trailing slash difference
909+
await provider._validate_resource_match(prm)
910+
911+
@pytest.mark.anyio
912+
async def test_accepts_server_url_with_trailing_slash(
913+
self, client_metadata: OAuthClientMetadata, mock_storage: MockTokenStorage
914+
) -> None:
915+
"""Server URL with trailing slash should match PRM resource."""
916+
provider = OAuthClientProvider(
917+
server_url="https://api.example.com/v1/mcp/",
918+
client_metadata=client_metadata,
919+
storage=mock_storage,
920+
)
921+
provider._initialized = True
922+
923+
prm = ProtectedResourceMetadata(
924+
resource=AnyHttpUrl("https://api.example.com/v1/mcp"),
925+
authorization_servers=[AnyHttpUrl("https://auth.example.com")],
926+
)
927+
# Should not raise - both normalize to the same URL with trailing slash
928+
await provider._validate_resource_match(prm)
929+
930+
@pytest.mark.anyio
931+
async def test_get_resource_url_uses_canonical_when_prm_mismatches(
932+
self, client_metadata: OAuthClientMetadata, mock_storage: MockTokenStorage
933+
) -> None:
934+
"""get_resource_url falls back to canonical URL when PRM resource doesn't match."""
935+
provider = OAuthClientProvider(
936+
server_url="https://api.example.com/v1/mcp",
937+
client_metadata=client_metadata,
938+
storage=mock_storage,
939+
)
940+
provider._initialized = True
941+
942+
# Set PRM with a resource that is NOT a parent of the server URL
943+
provider.context.protected_resource_metadata = ProtectedResourceMetadata(
944+
resource=AnyHttpUrl("https://other.example.com/mcp"),
945+
authorization_servers=[AnyHttpUrl("https://auth.example.com")],
946+
)
947+
948+
# get_resource_url should return the canonical server URL, not the PRM resource
949+
resource = provider.context.get_resource_url()
950+
assert resource == "https://api.example.com/v1/mcp"
951+
952+
821953
class TestRegistrationResponse:
822954
"""Test client registration response handling."""
823955

@@ -963,7 +1095,7 @@ async def test_auth_flow_with_no_tokens(self, oauth_provider: OAuthClientProvide
9631095
# Send a successful discovery response with minimal protected resource metadata
9641096
discovery_response = httpx.Response(
9651097
200,
966-
content=b'{"resource": "https://api.example.com/mcp", "authorization_servers": ["https://auth.example.com"]}',
1098+
content=b'{"resource": "https://api.example.com/v1/mcp", "authorization_servers": ["https://auth.example.com"]}',
9671099
request=discovery_request,
9681100
)
9691101

@@ -1116,7 +1248,7 @@ async def test_token_exchange_accepts_201_status(
11161248
# Send a successful discovery response with minimal protected resource metadata
11171249
discovery_response = httpx.Response(
11181250
200,
1119-
content=b'{"resource": "https://api.example.com/mcp", "authorization_servers": ["https://auth.example.com"]}',
1251+
content=b'{"resource": "https://api.example.com/v1/mcp", "authorization_servers": ["https://auth.example.com"]}',
11201252
request=discovery_request,
11211253
)
11221254

0 commit comments

Comments
 (0)