From 7bff13c1890a91ada27535ca01ac7da358c4d5e4 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Thu, 5 Feb 2026 15:37:59 +0000 Subject: [PATCH 1/7] fix: validate PRM resource match and handle DCR failure with pre-registered credentials Two conformance auth scenario fixes: 1. Resource mismatch validation (RFC 8707): After discovering Protected Resource Metadata, validate that the resource field matches the server URL before proceeding with authorization. If the PRM returns a resource from a different origin, raise OAuthFlowError. 2. Pre-registration fallback: When Dynamic Client Registration fails (e.g. server returns 404), fall back to pre-registered client credentials from storage instead of crashing. The conformance client now pre-loads client credentials from MCP_CONFORMANCE_CONTEXT when available. --- .github/actions/conformance/client.py | 23 +++++++++++++++++++++- src/mcp/client/auth/oauth2.py | 28 +++++++++++++++++++++++---- 2 files changed, 46 insertions(+), 5 deletions(-) diff --git a/.github/actions/conformance/client.py b/.github/actions/conformance/client.py index 2e1e7788b..b39e1cf39 100644 --- a/.github/actions/conformance/client.py +++ b/.github/actions/conformance/client.py @@ -275,6 +275,27 @@ async def run_client_credentials_basic(server_url: str) -> None: async def run_auth_code_client(server_url: str) -> None: """Authorization code flow (default for auth/* scenarios).""" callback_handler = ConformanceOAuthCallbackHandler() + storage = InMemoryTokenStorage() + + # Check for pre-registered client credentials from context + context_json = os.environ.get("MCP_CONFORMANCE_CONTEXT") + if context_json: + try: + context = json.loads(context_json) + client_id = context.get("client_id") + client_secret = context.get("client_secret") + if client_id: + await storage.set_client_info( + OAuthClientInformationFull( + client_id=client_id, + client_secret=client_secret, + redirect_uris=[AnyUrl("http://localhost:3000/callback")], + token_endpoint_auth_method="client_secret_basic" if client_secret else "none", + ) + ) + logger.debug(f"Pre-loaded client credentials: client_id={client_id}") + except json.JSONDecodeError: + pass oauth_auth = OAuthClientProvider( server_url=server_url, @@ -284,7 +305,7 @@ async def run_auth_code_client(server_url: str) -> None: grant_types=["authorization_code", "refresh_token"], response_types=["code"], ), - storage=InMemoryTokenStorage(), + storage=storage, redirect_handler=callback_handler.handle_redirect, callback_handler=callback_handler.handle_callback, client_metadata_url="https://conformance-test.local/client-metadata.json", diff --git a/src/mcp/client/auth/oauth2.py b/src/mcp/client/auth/oauth2.py index 98df4d25d..b07b1db91 100644 --- a/src/mcp/client/auth/oauth2.py +++ b/src/mcp/client/auth/oauth2.py @@ -18,7 +18,7 @@ import httpx from pydantic import BaseModel, Field, ValidationError -from mcp.client.auth.exceptions import OAuthFlowError, OAuthTokenError +from mcp.client.auth.exceptions import OAuthFlowError, OAuthRegistrationError, OAuthTokenError from mcp.client.auth.utils import ( build_oauth_authorization_server_metadata_discovery_urls, build_protected_resource_metadata_discovery_urls, @@ -476,6 +476,15 @@ async def _handle_oauth_metadata_response(self, response: httpx.Response) -> Non metadata = OAuthMetadata.model_validate_json(content) self.context.oauth_metadata = metadata + def _validate_resource_match(self, prm: ProtectedResourceMetadata) -> None: + """Validate that PRM resource matches the server URL per RFC 8707.""" + if not prm.resource: + return + default_resource = resource_url_from_server_url(self.context.server_url) + prm_resource = str(prm.resource) + if not check_resource_allowed(requested_resource=default_resource, configured_resource=prm_resource): + raise OAuthFlowError(f"Protected resource {prm_resource} does not match expected {default_resource}") + async def async_auth_flow(self, request: httpx.Request) -> AsyncGenerator[httpx.Request, httpx.Response]: """HTTPX auth flow integration.""" async with self.context.lock: @@ -517,6 +526,8 @@ async def async_auth_flow(self, request: httpx.Request) -> AsyncGenerator[httpx. prm = await handle_protected_resource_response(discovery_response) if prm: + # Validate PRM resource matches server URL (RFC 8707) + self._validate_resource_match(prm) self.context.protected_resource_metadata = prm # todo: try all authorization_servers to find the OASM @@ -575,9 +586,18 @@ async def async_auth_flow(self, request: httpx.Request) -> AsyncGenerator[httpx. self.context.get_authorization_base_url(self.context.server_url), ) registration_response = yield registration_request - client_information = await handle_registration_response(registration_response) - self.context.client_info = client_information - await self.context.storage.set_client_info(client_information) + try: + client_information = await handle_registration_response(registration_response) + self.context.client_info = client_information + await self.context.storage.set_client_info(client_information) + except OAuthRegistrationError: + # DCR failed — check for pre-registered client credentials + stored_client_info = await self.context.storage.get_client_info() + if stored_client_info: + logger.debug("DCR failed, using pre-registered client credentials") + self.context.client_info = stored_client_info + else: + raise # Step 5: Perform authorization and complete token exchange token_response = yield await self._perform_authorization() From 84b85c145cac9b938fd5979db477576bf1f32c14 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Thu, 5 Feb 2026 16:01:36 +0000 Subject: [PATCH 2/7] fix: remove unnecessary DCR fallback, rely on storage-loaded client info MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The _initialize() method already loads client_info from storage before the OAuth flow runs. When pre-registered credentials are in storage, the 'if not self.context.client_info' guard skips DCR entirely — no fallback needed. --- src/mcp/client/auth/oauth2.py | 17 ++++------------- 1 file changed, 4 insertions(+), 13 deletions(-) diff --git a/src/mcp/client/auth/oauth2.py b/src/mcp/client/auth/oauth2.py index b07b1db91..d52803672 100644 --- a/src/mcp/client/auth/oauth2.py +++ b/src/mcp/client/auth/oauth2.py @@ -18,7 +18,7 @@ import httpx from pydantic import BaseModel, Field, ValidationError -from mcp.client.auth.exceptions import OAuthFlowError, OAuthRegistrationError, OAuthTokenError +from mcp.client.auth.exceptions import OAuthFlowError, OAuthTokenError from mcp.client.auth.utils import ( build_oauth_authorization_server_metadata_discovery_urls, build_protected_resource_metadata_discovery_urls, @@ -586,18 +586,9 @@ async def async_auth_flow(self, request: httpx.Request) -> AsyncGenerator[httpx. self.context.get_authorization_base_url(self.context.server_url), ) registration_response = yield registration_request - try: - client_information = await handle_registration_response(registration_response) - self.context.client_info = client_information - await self.context.storage.set_client_info(client_information) - except OAuthRegistrationError: - # DCR failed — check for pre-registered client credentials - stored_client_info = await self.context.storage.get_client_info() - if stored_client_info: - logger.debug("DCR failed, using pre-registered client credentials") - self.context.client_info = stored_client_info - else: - raise + client_information = await handle_registration_response(registration_response) + self.context.client_info = client_information + await self.context.storage.set_client_info(client_information) # Step 5: Perform authorization and complete token exchange token_response = yield await self._perform_authorization() From f966fab411d4b025667990552c258b48d5eac128 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Thu, 5 Feb 2026 16:13:59 +0000 Subject: [PATCH 3/7] ci: bump conformance to 0.1.13 --- .github/workflows/conformance.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml index cd9c4b01a..d876da00b 100644 --- a/.github/workflows/conformance.yml +++ b/.github/workflows/conformance.yml @@ -42,4 +42,4 @@ jobs: with: node-version: 24 - run: uv sync --frozen --all-extras --package mcp - - run: npx @modelcontextprotocol/conformance@0.1.10 client --command 'uv run --frozen python .github/actions/conformance/client.py' --suite all + - run: npx @modelcontextprotocol/conformance@0.1.13 client --command 'uv run --frozen python .github/actions/conformance/client.py' --suite all From 297ededdafa1c367573bbcfcbd7c6222142c2bf9 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Thu, 5 Feb 2026 16:18:02 +0000 Subject: [PATCH 4/7] fix: normalize trailing slashes in PRM resource validation Pydantic AnyHttpUrl adds a trailing slash to root URLs (e.g. "https://example.com/") while resource_url_from_server_url may return without one. This caused check_resource_allowed to reject valid root-URL servers due to path length mismatch. --- src/mcp/client/auth/oauth2.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/mcp/client/auth/oauth2.py b/src/mcp/client/auth/oauth2.py index d52803672..8a5f6e2c3 100644 --- a/src/mcp/client/auth/oauth2.py +++ b/src/mcp/client/auth/oauth2.py @@ -482,6 +482,12 @@ def _validate_resource_match(self, prm: ProtectedResourceMetadata) -> None: return default_resource = resource_url_from_server_url(self.context.server_url) prm_resource = str(prm.resource) + # Normalize: Pydantic AnyHttpUrl adds trailing slash to root URLs + # (e.g. "https://example.com/") while resource_url_from_server_url may not. + if not default_resource.endswith("/"): + default_resource += "/" + if not prm_resource.endswith("/"): + prm_resource += "/" if not check_resource_allowed(requested_resource=default_resource, configured_resource=prm_resource): raise OAuthFlowError(f"Protected resource {prm_resource} does not match expected {default_resource}") From ff57462343805641c905822a3a94aca433e6f6ff Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Thu, 5 Feb 2026 16:53:24 +0000 Subject: [PATCH 5/7] feat: add validate_resource_url callback to OAuthClientProvider Allows clients to override or disable PRM resource validation. Called with (server_url, prm_resource) and can raise to reject, return to accept, or implement custom logic. When not provided, default behavior validates per RFC 8707 and rejects mismatches. --- src/mcp/client/auth/oauth2.py | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/src/mcp/client/auth/oauth2.py b/src/mcp/client/auth/oauth2.py index 8a5f6e2c3..fb12675af 100644 --- a/src/mcp/client/auth/oauth2.py +++ b/src/mcp/client/auth/oauth2.py @@ -229,6 +229,7 @@ def __init__( callback_handler: Callable[[], Awaitable[tuple[str, str | None]]] | None = None, timeout: float = 300.0, client_metadata_url: str | None = None, + validate_resource_url: Callable[[str, str | None], Awaitable[str | None]] | None = None, ): """Initialize OAuth2 authentication. @@ -243,6 +244,11 @@ def __init__( advertises client_id_metadata_document_supported=true, this URL will be used as the client_id instead of performing dynamic client registration. Must be a valid HTTPS URL with a non-root pathname. + validate_resource_url: Optional callback to override resource URL validation. + Called with (server_url, prm_resource) where prm_resource is the resource + from Protected Resource Metadata (or None if not present). Must return the + resource URL to use, or None to omit it. If not provided, default validation + rejects mismatched resources per RFC 8707. Raises: ValueError: If client_metadata_url is provided but not a valid HTTPS URL @@ -263,6 +269,7 @@ def __init__( timeout=timeout, client_metadata_url=client_metadata_url, ) + self._validate_resource_url_callback = validate_resource_url self._initialized = False async def _handle_protected_resource_response(self, response: httpx.Response) -> bool: @@ -476,12 +483,17 @@ async def _handle_oauth_metadata_response(self, response: httpx.Response) -> Non metadata = OAuthMetadata.model_validate_json(content) self.context.oauth_metadata = metadata - def _validate_resource_match(self, prm: ProtectedResourceMetadata) -> None: + async def _validate_resource_match(self, prm: ProtectedResourceMetadata) -> None: """Validate that PRM resource matches the server URL per RFC 8707.""" - if not prm.resource: + prm_resource = str(prm.resource) if prm.resource else None + + if self._validate_resource_url_callback is not None: + await self._validate_resource_url_callback(self.context.server_url, prm_resource) + return + + if not prm_resource: return default_resource = resource_url_from_server_url(self.context.server_url) - prm_resource = str(prm.resource) # Normalize: Pydantic AnyHttpUrl adds trailing slash to root URLs # (e.g. "https://example.com/") while resource_url_from_server_url may not. if not default_resource.endswith("/"): @@ -533,7 +545,7 @@ async def async_auth_flow(self, request: httpx.Request) -> AsyncGenerator[httpx. prm = await handle_protected_resource_response(discovery_response) if prm: # Validate PRM resource matches server URL (RFC 8707) - self._validate_resource_match(prm) + await self._validate_resource_match(prm) self.context.protected_resource_metadata = prm # todo: try all authorization_servers to find the OASM From fbd97ee6a6fd7e2e36a7ef286b0d315c02d385d1 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Thu, 5 Feb 2026 17:01:19 +0000 Subject: [PATCH 6/7] fix: correct PRM resource in auth tests to match server URL The tests used a PRM resource (https://api.example.com/mcp) that didn't match the server URL (https://api.example.com/v1/mcp). This was silently ignored before resource validation was added. --- tests/client/test_auth.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/client/test_auth.py b/tests/client/test_auth.py index 7ad24f2df..4d8595357 100644 --- a/tests/client/test_auth.py +++ b/tests/client/test_auth.py @@ -963,7 +963,7 @@ async def test_auth_flow_with_no_tokens(self, oauth_provider: OAuthClientProvide # Send a successful discovery response with minimal protected resource metadata discovery_response = httpx.Response( 200, - content=b'{"resource": "https://api.example.com/mcp", "authorization_servers": ["https://auth.example.com"]}', + content=b'{"resource": "https://api.example.com/v1/mcp", "authorization_servers": ["https://auth.example.com"]}', request=discovery_request, ) @@ -1116,7 +1116,7 @@ async def test_token_exchange_accepts_201_status( # Send a successful discovery response with minimal protected resource metadata discovery_response = httpx.Response( 200, - content=b'{"resource": "https://api.example.com/mcp", "authorization_servers": ["https://auth.example.com"]}', + content=b'{"resource": "https://api.example.com/v1/mcp", "authorization_servers": ["https://auth.example.com"]}', request=discovery_request, ) From 0e3d18ab822a7ab29eb74c8424eba17973001e60 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Thu, 5 Feb 2026 19:18:07 +0000 Subject: [PATCH 7/7] test: add coverage for PRM resource validation Tests for resource mismatch rejection, matching resources, custom callback override, and root URL trailing slash normalization. --- src/mcp/client/auth/oauth2.py | 2 +- tests/client/test_auth.py | 83 +++++++++++++++++++++++++++++++++++ 2 files changed, 84 insertions(+), 1 deletion(-) diff --git a/src/mcp/client/auth/oauth2.py b/src/mcp/client/auth/oauth2.py index fb12675af..1ce698f06 100644 --- a/src/mcp/client/auth/oauth2.py +++ b/src/mcp/client/auth/oauth2.py @@ -492,7 +492,7 @@ async def _validate_resource_match(self, prm: ProtectedResourceMetadata) -> None return if not prm_resource: - return + return # pragma: no cover default_resource = resource_url_from_server_url(self.context.server_url) # Normalize: Pydantic AnyHttpUrl adds trailing slash to root URLs # (e.g. "https://example.com/") while resource_url_from_server_url may not. diff --git a/tests/client/test_auth.py b/tests/client/test_auth.py index 4d8595357..bd6b95d0c 100644 --- a/tests/client/test_auth.py +++ b/tests/client/test_auth.py @@ -11,6 +11,7 @@ from pydantic import AnyHttpUrl, AnyUrl from mcp.client.auth import OAuthClientProvider, PKCEParameters +from mcp.client.auth.exceptions import OAuthFlowError from mcp.client.auth.utils import ( build_oauth_authorization_server_metadata_discovery_urls, build_protected_resource_metadata_discovery_urls, @@ -818,6 +819,88 @@ async def test_resource_param_included_with_protected_resource_metadata(self, oa assert "resource=" in content +class TestResourceValidation: + """Test PRM resource validation in OAuthClientProvider.""" + + @pytest.mark.anyio + async def test_rejects_mismatched_resource(self, client_metadata, mock_storage): + """Client must reject PRM resource that doesn't match server URL.""" + provider = OAuthClientProvider( + server_url="https://api.example.com/v1/mcp", + client_metadata=client_metadata, + storage=mock_storage, + ) + provider._initialized = True + + prm = ProtectedResourceMetadata( + resource=AnyHttpUrl("https://evil.example.com/mcp"), + authorization_servers=[AnyHttpUrl("https://auth.example.com")], + ) + with pytest.raises(OAuthFlowError, match="does not match expected"): + await provider._validate_resource_match(prm) + + @pytest.mark.anyio + async def test_accepts_matching_resource(self, client_metadata, mock_storage): + """Client must accept PRM resource that matches server URL.""" + provider = OAuthClientProvider( + server_url="https://api.example.com/v1/mcp", + client_metadata=client_metadata, + storage=mock_storage, + ) + provider._initialized = True + + prm = ProtectedResourceMetadata( + resource=AnyHttpUrl("https://api.example.com/v1/mcp"), + authorization_servers=[AnyHttpUrl("https://auth.example.com")], + ) + # Should not raise + await provider._validate_resource_match(prm) + + @pytest.mark.anyio + async def test_custom_validate_resource_url_callback(self, client_metadata, mock_storage): + """Custom callback overrides default validation.""" + callback_called_with: list[tuple[str, str | None]] = [] + + async def custom_validate(server_url: str, prm_resource: str | None) -> None: + callback_called_with.append((server_url, prm_resource)) + + provider = OAuthClientProvider( + server_url="https://api.example.com/v1/mcp", + client_metadata=client_metadata, + storage=mock_storage, + validate_resource_url=custom_validate, + ) + provider._initialized = True + + # This would normally fail default validation (different origin), + # but custom callback accepts it + prm = ProtectedResourceMetadata( + resource=AnyHttpUrl("https://evil.example.com/mcp"), + authorization_servers=[AnyHttpUrl("https://auth.example.com")], + ) + await provider._validate_resource_match(prm) + assert len(callback_called_with) == 1 + assert callback_called_with[0][0] == "https://api.example.com/v1/mcp" + assert callback_called_with[0][1] == "https://evil.example.com/mcp" + + @pytest.mark.anyio + async def test_accepts_root_url_with_trailing_slash(self, client_metadata, mock_storage): + """Root URLs with trailing slash normalization should match.""" + provider = OAuthClientProvider( + server_url="https://api.example.com", + client_metadata=client_metadata, + storage=mock_storage, + ) + provider._initialized = True + + prm = ProtectedResourceMetadata( + resource=AnyHttpUrl("https://api.example.com/"), + authorization_servers=[AnyHttpUrl("https://auth.example.com")], + ) + # Should not raise despite trailing slash difference + await provider._validate_resource_match(prm) + + class TestRegistrationResponse: """Test client registration response handling."""