From d23463370975e3c8b636d50a50e5c93dd19f847e Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Mon, 16 Feb 2026 17:28:50 +0000 Subject: [PATCH 1/2] ci: backport conformance tests from main to v1.x Backport conformance CI pipeline from main branch with v1.x adaptations: - Add v1.x to push/PR branch triggers, add workflow_dispatch - Remove continue-on-error from both jobs - Align server and client conformance package versions to 0.1.13 - Adapt client.py for v1.x API differences (3-tuple transport, RequestContext type, camelCase field names) - Add expected-failures baseline for auth/resource-mismatch (needs RFC 8707 resource validation backport from main) Server conformance: 40/40 passing Client conformance: 262/263 passing (baseline clean) --- .github/actions/conformance/client.py | 367 ++++++++++++++++++ .../actions/conformance/expected-failures.yml | 9 + .github/actions/conformance/run-server.sh | 30 ++ .github/workflows/conformance.yml | 44 +++ 4 files changed, 450 insertions(+) create mode 100644 .github/actions/conformance/client.py create mode 100644 .github/actions/conformance/expected-failures.yml create mode 100755 .github/actions/conformance/run-server.sh create mode 100644 .github/workflows/conformance.yml diff --git a/.github/actions/conformance/client.py b/.github/actions/conformance/client.py new file mode 100644 index 000000000..a13742e6c --- /dev/null +++ b/.github/actions/conformance/client.py @@ -0,0 +1,367 @@ +"""MCP unified conformance test client. + +This client is designed to work with the @modelcontextprotocol/conformance npm package. +It handles all conformance test scenarios via environment variables and CLI arguments. + +Contract: + - MCP_CONFORMANCE_SCENARIO env var -> scenario name + - MCP_CONFORMANCE_CONTEXT env var -> optional JSON (for client-credentials scenarios) + - Server URL as last CLI argument (sys.argv[1]) + - Must exit 0 within 30 seconds + +Scenarios: + initialize - Connect, initialize, list tools, close + tools_call - Connect, call add_numbers(a=5, b=3), close + sse-retry - Connect, call test_reconnection, close + elicitation-sep1034-client-defaults - Elicitation with default accept callback + auth/client-credentials-jwt - Client credentials with private_key_jwt + auth/client-credentials-basic - Client credentials with client_secret_basic + auth/* - Authorization code flow (default for auth scenarios) +""" + +import asyncio +import json +import logging +import os +import sys +from collections.abc import Callable, Coroutine +from typing import Any, cast +from urllib.parse import parse_qs, urlparse + +import httpx +from pydantic import AnyUrl + +from mcp import ClientSession, types +from mcp.client.auth import OAuthClientProvider, TokenStorage +from mcp.client.auth.extensions.client_credentials import ( + ClientCredentialsOAuthProvider, + PrivateKeyJWTOAuthProvider, + SignedJWTParameters, +) +from mcp.client.streamable_http import streamable_http_client +from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata, OAuthToken +from mcp.shared.context import RequestContext + +# Set up logging to stderr (stdout is for conformance test output) +logging.basicConfig( + level=logging.DEBUG, + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", + stream=sys.stderr, +) +logger = logging.getLogger(__name__) + +# Type for async scenario handler functions +ScenarioHandler = Callable[[str], Coroutine[Any, None, None]] + +# Registry of scenario handlers +HANDLERS: dict[str, ScenarioHandler] = {} + + +def register(name: str) -> Callable[[ScenarioHandler], ScenarioHandler]: + """Register a scenario handler.""" + + def decorator(fn: ScenarioHandler) -> ScenarioHandler: + HANDLERS[name] = fn + return fn + + return decorator + + +def get_conformance_context() -> dict[str, Any]: + """Load conformance test context from MCP_CONFORMANCE_CONTEXT environment variable.""" + context_json = os.environ.get("MCP_CONFORMANCE_CONTEXT") + if not context_json: + raise RuntimeError( + "MCP_CONFORMANCE_CONTEXT environment variable not set. " + "Expected JSON with client_id, client_secret, and/or private_key_pem." + ) + try: + return json.loads(context_json) + except json.JSONDecodeError as e: + raise RuntimeError(f"Failed to parse MCP_CONFORMANCE_CONTEXT as JSON: {e}") from e + + +class InMemoryTokenStorage(TokenStorage): + """Simple in-memory token storage for conformance testing.""" + + def __init__(self) -> None: + self._tokens: OAuthToken | None = None + self._client_info: OAuthClientInformationFull | None = None + + async def get_tokens(self) -> OAuthToken | None: + return self._tokens + + async def set_tokens(self, tokens: OAuthToken) -> None: + self._tokens = tokens + + async def get_client_info(self) -> OAuthClientInformationFull | None: + return self._client_info + + async def set_client_info(self, client_info: OAuthClientInformationFull) -> None: + self._client_info = client_info + + +class ConformanceOAuthCallbackHandler: + """OAuth callback handler that automatically fetches the authorization URL + and extracts the auth code, without requiring user interaction. + """ + + def __init__(self) -> None: + self._auth_code: str | None = None + self._state: str | None = None + + async def handle_redirect(self, authorization_url: str) -> None: + """Fetch the authorization URL and extract the auth code from the redirect.""" + logger.debug(f"Fetching authorization URL: {authorization_url}") + + async with httpx.AsyncClient() as client: + response = await client.get( + authorization_url, + follow_redirects=False, + ) + + if response.status_code in (301, 302, 303, 307, 308): + location = cast(str, response.headers.get("location")) + if location: + redirect_url = urlparse(location) + query_params: dict[str, list[str]] = parse_qs(redirect_url.query) + + if "code" in query_params: + self._auth_code = query_params["code"][0] + state_values = query_params.get("state") + self._state = state_values[0] if state_values else None + logger.debug(f"Got auth code from redirect: {self._auth_code[:10]}...") + return + else: + raise RuntimeError(f"No auth code in redirect URL: {location}") + else: + raise RuntimeError(f"No redirect location received from {authorization_url}") + else: + raise RuntimeError(f"Expected redirect response, got {response.status_code} from {authorization_url}") + + async def handle_callback(self) -> tuple[str, str | None]: + """Return the captured auth code and state.""" + if self._auth_code is None: + raise RuntimeError("No authorization code available - was handle_redirect called?") + auth_code = self._auth_code + state = self._state + self._auth_code = None + self._state = None + return auth_code, state + + +# --- Scenario Handlers --- + + +@register("initialize") +async def run_initialize(server_url: str) -> None: + """Connect, initialize, list tools, close.""" + async with streamable_http_client(url=server_url) as (read_stream, write_stream, _): + async with ClientSession(read_stream, write_stream) as session: + await session.initialize() + logger.debug("Initialized successfully") + await session.list_tools() + logger.debug("Listed tools successfully") + + +@register("tools_call") +async def run_tools_call(server_url: str) -> None: + """Connect, initialize, list tools, call add_numbers(a=5, b=3), close.""" + async with streamable_http_client(url=server_url) as (read_stream, write_stream, _): + async with ClientSession(read_stream, write_stream) as session: + await session.initialize() + await session.list_tools() + result = await session.call_tool("add_numbers", {"a": 5, "b": 3}) + logger.debug(f"add_numbers result: {result}") + + +@register("sse-retry") +async def run_sse_retry(server_url: str) -> None: + """Connect, initialize, list tools, call test_reconnection, close.""" + async with streamable_http_client(url=server_url) as (read_stream, write_stream, _): + async with ClientSession(read_stream, write_stream) as session: + await session.initialize() + await session.list_tools() + result = await session.call_tool("test_reconnection", {}) + logger.debug(f"test_reconnection result: {result}") + + +async def default_elicitation_callback( + context: RequestContext["ClientSession", Any], + params: types.ElicitRequestParams, +) -> types.ElicitResult | types.ErrorData: + """Accept elicitation and apply defaults from the schema (SEP-1034).""" + content: dict[str, str | int | float | bool | list[str] | None] = {} + + # For form mode, extract defaults from the requested_schema + if isinstance(params, types.ElicitRequestFormParams): + schema = params.requestedSchema + logger.debug(f"Elicitation schema: {schema}") + properties = schema.get("properties", {}) + for prop_name, prop_schema in properties.items(): + if "default" in prop_schema: + content[prop_name] = prop_schema["default"] + logger.debug(f"Applied defaults: {content}") + + return types.ElicitResult(action="accept", content=content) + + +@register("elicitation-sep1034-client-defaults") +async def run_elicitation_defaults(server_url: str) -> None: + """Connect with elicitation callback that applies schema defaults.""" + async with streamable_http_client(url=server_url) as (read_stream, write_stream, _): + async with ClientSession( + read_stream, write_stream, elicitation_callback=default_elicitation_callback + ) as session: + await session.initialize() + await session.list_tools() + result = await session.call_tool("test_client_elicitation_defaults", {}) + logger.debug(f"test_client_elicitation_defaults result: {result}") + + +@register("auth/client-credentials-jwt") +async def run_client_credentials_jwt(server_url: str) -> None: + """Client credentials flow with private_key_jwt authentication.""" + context = get_conformance_context() + client_id = context.get("client_id") + private_key_pem = context.get("private_key_pem") + signing_algorithm = context.get("signing_algorithm", "ES256") + + if not client_id: + raise RuntimeError("MCP_CONFORMANCE_CONTEXT missing 'client_id'") + if not private_key_pem: + raise RuntimeError("MCP_CONFORMANCE_CONTEXT missing 'private_key_pem'") + + jwt_params = SignedJWTParameters( + issuer=client_id, + subject=client_id, + signing_algorithm=signing_algorithm, + signing_key=private_key_pem, + ) + + oauth_auth = PrivateKeyJWTOAuthProvider( + server_url=server_url, + storage=InMemoryTokenStorage(), + client_id=client_id, + assertion_provider=jwt_params.create_assertion_provider(), + ) + + await _run_auth_session(server_url, oauth_auth) + + +@register("auth/client-credentials-basic") +async def run_client_credentials_basic(server_url: str) -> None: + """Client credentials flow with client_secret_basic authentication.""" + context = get_conformance_context() + client_id = context.get("client_id") + client_secret = context.get("client_secret") + + if not client_id: + raise RuntimeError("MCP_CONFORMANCE_CONTEXT missing 'client_id'") + if not client_secret: + raise RuntimeError("MCP_CONFORMANCE_CONTEXT missing 'client_secret'") + + oauth_auth = ClientCredentialsOAuthProvider( + server_url=server_url, + storage=InMemoryTokenStorage(), + client_id=client_id, + client_secret=client_secret, + token_endpoint_auth_method="client_secret_basic", + ) + + await _run_auth_session(server_url, oauth_auth) + + +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: + logger.exception("Failed to parse MCP_CONFORMANCE_CONTEXT") + + oauth_auth = OAuthClientProvider( + server_url=server_url, + client_metadata=OAuthClientMetadata( + client_name="conformance-client", + redirect_uris=[AnyUrl("http://localhost:3000/callback")], + grant_types=["authorization_code", "refresh_token"], + response_types=["code"], + ), + storage=storage, + redirect_handler=callback_handler.handle_redirect, + callback_handler=callback_handler.handle_callback, + client_metadata_url="https://conformance-test.local/client-metadata.json", + ) + + await _run_auth_session(server_url, oauth_auth) + + +async def _run_auth_session(server_url: str, oauth_auth: OAuthClientProvider) -> None: + """Common session logic for all OAuth flows.""" + client = httpx.AsyncClient(auth=oauth_auth, timeout=30.0) + async with streamable_http_client(url=server_url, http_client=client) as (read_stream, write_stream, _): + async with ClientSession( + read_stream, write_stream, elicitation_callback=default_elicitation_callback + ) as session: + await session.initialize() + logger.debug("Initialized successfully") + + tools_result = await session.list_tools() + logger.debug(f"Listed tools: {[t.name for t in tools_result.tools]}") + + # Call the first available tool (different tests have different tools) + if tools_result.tools: + tool_name = tools_result.tools[0].name + try: + result = await session.call_tool(tool_name, {}) + logger.debug(f"Called {tool_name}, result: {result}") + except Exception as e: + logger.debug(f"Tool call result/error: {e}") + + logger.debug("Connection closed successfully") + + +def main() -> None: + """Main entry point for the conformance client.""" + if len(sys.argv) < 2: + print(f"Usage: {sys.argv[0]} ", file=sys.stderr) + sys.exit(1) + + server_url = sys.argv[1] + scenario = os.environ.get("MCP_CONFORMANCE_SCENARIO") + + if scenario: + logger.debug(f"Running explicit scenario '{scenario}' against {server_url}") + handler = HANDLERS.get(scenario) + if handler: + asyncio.run(handler(server_url)) + elif scenario.startswith("auth/"): + asyncio.run(run_auth_code_client(server_url)) + else: + print(f"Unknown scenario: {scenario}", file=sys.stderr) + sys.exit(1) + else: + logger.debug(f"Running default auth flow against {server_url}") + asyncio.run(run_auth_code_client(server_url)) + + +if __name__ == "__main__": + main() diff --git a/.github/actions/conformance/expected-failures.yml b/.github/actions/conformance/expected-failures.yml new file mode 100644 index 000000000..010961e6d --- /dev/null +++ b/.github/actions/conformance/expected-failures.yml @@ -0,0 +1,9 @@ +# Known conformance test failures for v1.x +# These are tracked and should be removed as they're fixed. +# +# auth/resource-mismatch: Client must validate that the Protected Resource +# Metadata (PRM) resource field matches the server URL before proceeding +# with authorization (RFC 8707). Implemented on main (PR #2010), needs +# backport to v1.x. +client: + - auth/resource-mismatch diff --git a/.github/actions/conformance/run-server.sh b/.github/actions/conformance/run-server.sh new file mode 100755 index 000000000..b11c4fce5 --- /dev/null +++ b/.github/actions/conformance/run-server.sh @@ -0,0 +1,30 @@ +#!/bin/bash +set -e + +PORT="${PORT:-3001}" +SERVER_URL="http://localhost:${PORT}/mcp" + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR/../../.." + +# Start everything-server +uv run --frozen mcp-everything-server --port "$PORT" & +SERVER_PID=$! +trap "kill $SERVER_PID 2>/dev/null || true; wait $SERVER_PID 2>/dev/null || true" EXIT + +# Wait for server to be ready +MAX_RETRIES=30 +RETRY_COUNT=0 +while ! curl -s "$SERVER_URL" > /dev/null 2>&1; do + RETRY_COUNT=$((RETRY_COUNT + 1)) + if [ $RETRY_COUNT -ge $MAX_RETRIES ]; then + echo "Server failed to start after ${MAX_RETRIES} retries" >&2 + exit 1 + fi + sleep 0.5 +done + +echo "Server ready at $SERVER_URL" + +# Run conformance tests +npx @modelcontextprotocol/conformance@0.1.13 server --url "$SERVER_URL" "$@" diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml new file mode 100644 index 000000000..19c557a13 --- /dev/null +++ b/.github/workflows/conformance.yml @@ -0,0 +1,44 @@ +name: Conformance Tests + +on: + push: + branches: [v1.x] + pull_request: + branches: [v1.x] + workflow_dispatch: + +concurrency: + group: conformance-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + server-conformance: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + - uses: astral-sh/setup-uv@803947b9bd8e9f986429fa0c5a41c367cd732b41 # v7.2.1 + with: + enable-cache: true + version: 0.9.5 + - uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 + with: + node-version: 24 + - run: uv sync --frozen --all-extras --package mcp-everything-server + - run: ./.github/actions/conformance/run-server.sh + + client-conformance: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + - uses: astral-sh/setup-uv@803947b9bd8e9f986429fa0c5a41c367cd732b41 # v7.2.1 + with: + enable-cache: true + version: 0.9.5 + - uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 + with: + node-version: 24 + - run: uv sync --frozen --all-extras --package mcp + - run: npx @modelcontextprotocol/conformance@0.1.13 client --command 'uv run --frozen python .github/actions/conformance/client.py' --suite all --expected-failures .github/actions/conformance/expected-failures.yml From b899f8e9ffec9d9fdae15845c887e0ed8c58b6da Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Mon, 16 Feb 2026 17:53:31 +0000 Subject: [PATCH 2/2] fix: add RFC 8707 resource validation to OAuth client Backport from main (PR #2010). The client now validates that the Protected Resource Metadata resource field matches the server URL before proceeding with authorization, rejecting mismatched resources per RFC 8707. This fixes the auth/resource-mismatch conformance test, bringing client conformance to 251/251 (100%) on v1.x. --- .../actions/conformance/expected-failures.yml | 9 +- src/mcp/client/auth/oauth2.py | 17 ++++ tests/client/test_auth.py | 85 ++++++++++++++++++- 3 files changed, 103 insertions(+), 8 deletions(-) diff --git a/.github/actions/conformance/expected-failures.yml b/.github/actions/conformance/expected-failures.yml index 010961e6d..b542e788b 100644 --- a/.github/actions/conformance/expected-failures.yml +++ b/.github/actions/conformance/expected-failures.yml @@ -1,9 +1,4 @@ # Known conformance test failures for v1.x # These are tracked and should be removed as they're fixed. -# -# auth/resource-mismatch: Client must validate that the Protected Resource -# Metadata (PRM) resource field matches the server URL before proceeding -# with authorization (RFC 8707). Implemented on main (PR #2010), needs -# backport to v1.x. -client: - - auth/resource-mismatch +server: [] +client: [] diff --git a/src/mcp/client/auth/oauth2.py b/src/mcp/client/auth/oauth2.py index cd96a7566..c25ded2e2 100644 --- a/src/mcp/client/auth/oauth2.py +++ b/src/mcp/client/auth/oauth2.py @@ -267,6 +267,21 @@ def __init__( ) self._initialized = False + async def _validate_resource_match(self, prm: ProtectedResourceMetadata) -> None: + """Validate that PRM resource matches the server URL per RFC 8707.""" + prm_resource = str(prm.resource) if prm.resource else None + if not prm_resource: + 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. + 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}") + async def _handle_protected_resource_response(self, response: httpx.Response) -> bool: """ Handle protected resource metadata discovery response. @@ -520,6 +535,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) + await self._validate_resource_match(prm) self.context.protected_resource_metadata = prm # todo: try all authorization_servers to find the OASM diff --git a/tests/client/test_auth.py b/tests/client/test_auth.py index 593d5cfe0..5f8bc1410 100644 --- a/tests/client/test_auth.py +++ b/tests/client/test_auth.py @@ -13,6 +13,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, @@ -965,7 +966,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, ) @@ -2030,3 +2031,85 @@ async def callback_handler() -> tuple[str, str | None]: await auth_flow.asend(final_response) except StopAsyncIteration: pass + + +@pytest.mark.anyio +async def test_validate_resource_rejects_mismatched_resource( + client_metadata: OAuthClientMetadata, mock_storage: MockTokenStorage +) -> None: + """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_validate_resource_accepts_matching_resource( + client_metadata: OAuthClientMetadata, mock_storage: MockTokenStorage +) -> None: + """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_validate_resource_accepts_root_url_with_trailing_slash( + client_metadata: OAuthClientMetadata, mock_storage: MockTokenStorage +) -> None: + """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 - both already have trailing slashes + await provider._validate_resource_match(prm) + + +@pytest.mark.anyio +async def test_get_resource_url_falls_back_when_prm_mismatches( + client_metadata: OAuthClientMetadata, mock_storage: MockTokenStorage +) -> None: + """get_resource_url returns canonical URL when PRM resource doesn't match.""" + provider = OAuthClientProvider( + server_url="https://api.example.com/v1/mcp", + client_metadata=client_metadata, + storage=mock_storage, + ) + provider._initialized = True + + # Set PRM with a resource that is NOT a parent of the server URL + provider.context.protected_resource_metadata = ProtectedResourceMetadata( + resource=AnyHttpUrl("https://other.example.com/mcp"), + authorization_servers=[AnyHttpUrl("https://auth.example.com")], + ) + + # get_resource_url should return the canonical server URL, not the PRM resource + assert provider.context.get_resource_url() == "https://api.example.com/v1/mcp"