From de7f8c4cdf6822d75740def7c85caec8cd71c77c Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Tue, 20 Jan 2026 17:24:42 +0000 Subject: [PATCH 01/10] Add conformance testing CI pipeline Add a conformance testing pipeline that validates the Python SDK against the @modelcontextprotocol/conformance npm package. Changes: - Add conformance-client example that handles all test scenarios (auth flows, metadata, scope handling) - Add run-server-conformance.sh script to test the everything-server - Add GitHub Actions workflow with server-conformance and client-conformance jobs - Both jobs use continue-on-error: true to match TS SDK approach The conformance client supports: - OAuth authorization code flow (default) - Client credentials with client_secret_basic - Client credentials with private_key_jwt - All auth/metadata and sep-835 scope scenarios Test results: - Server: 24/24 passed - Client: 192/192 passed (metadata, auth, sep-835 suites) --- .github/workflows/conformance.yml | 45 +++ .../mcp_conformance_client/__init__.py | 338 ++++++++++++++++++ .../mcp_conformance_client/__main__.py | 6 + .../clients/conformance-client/pyproject.toml | 42 +++ scripts/run-server-conformance.sh | 30 ++ uv.lock | 30 ++ 6 files changed, 491 insertions(+) create mode 100644 .github/workflows/conformance.yml create mode 100644 examples/clients/conformance-client/mcp_conformance_client/__init__.py create mode 100644 examples/clients/conformance-client/mcp_conformance_client/__main__.py create mode 100644 examples/clients/conformance-client/pyproject.toml create mode 100755 scripts/run-server-conformance.sh diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml new file mode 100644 index 0000000000..27f87905f8 --- /dev/null +++ b/.github/workflows/conformance.yml @@ -0,0 +1,45 @@ +name: Conformance Tests + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +concurrency: + group: conformance-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + server-conformance: + runs-on: ubuntu-latest + continue-on-error: true + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v7 + with: + enable-cache: true + version: 0.9.5 + - uses: actions/setup-node@v4 + with: + node-version: 24 + - run: uv sync --frozen --all-extras --all-packages + - run: ./scripts/run-server-conformance.sh + + client-conformance: + runs-on: ubuntu-latest + continue-on-error: true + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v7 + with: + enable-cache: true + version: 0.9.5 + - uses: actions/setup-node@v4 + with: + node-version: 24 + - run: uv sync --frozen --all-extras --all-packages + - run: npx @modelcontextprotocol/conformance client --command 'uv run --frozen mcp-conformance-client' diff --git a/examples/clients/conformance-client/mcp_conformance_client/__init__.py b/examples/clients/conformance-client/mcp_conformance_client/__init__.py new file mode 100644 index 0000000000..8b9a452663 --- /dev/null +++ b/examples/clients/conformance-client/mcp_conformance_client/__init__.py @@ -0,0 +1,338 @@ +"""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 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 +from pydantic import AnyUrl + +# 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}") + + +@register("elicitation-sep1034-client-defaults") +async def run_elicitation_defaults(server_url: str) -> None: + """Connect with elicitation callback that accepts with empty content, call test tool.""" + + async def elicitation_callback( + context: RequestContext[ClientSession, Any], # noqa: ARG001 + params: types.ElicitRequestParams, # noqa: ARG001 + ) -> types.ElicitResult | types.ErrorData: + """Accept elicitation with empty content (defaults).""" + return types.ElicitResult(action="accept", content={}) + + async with streamable_http_client(url=server_url) as (read_stream, write_stream, _): + async with ClientSession(read_stream, write_stream, elicitation_callback=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() + + 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=InMemoryTokenStorage(), + redirect_handler=callback_handler.handle_redirect, + callback_handler=callback_handler.handle_callback, + ) + + 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) 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]}") + + try: + result = await session.call_tool("test-tool", {}) + logger.debug(f"Called test-tool, 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] + + # Check for explicit scenario override (for manual testing) + scenario = os.environ.get("MCP_CONFORMANCE_SCENARIO") + + if scenario: + logger.debug(f"Running explicit scenario '{scenario}' against {server_url}") + handler = HANDLERS.get(scenario) + try: + 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) + except Exception: + logger.exception("Client failed") + sys.exit(1) + else: + # No explicit scenario - run default auth flow + # The conformance framework tests different behaviors by configuring + # its mock server; our client just needs to handle OAuth properly + logger.debug(f"Running default auth flow against {server_url}") + try: + asyncio.run(run_auth_code_client(server_url)) + except Exception: + logger.exception("Client failed") + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/examples/clients/conformance-client/mcp_conformance_client/__main__.py b/examples/clients/conformance-client/mcp_conformance_client/__main__.py new file mode 100644 index 0000000000..1b8f8acb09 --- /dev/null +++ b/examples/clients/conformance-client/mcp_conformance_client/__main__.py @@ -0,0 +1,6 @@ +"""Allow running the module with python -m.""" + +from . import main + +if __name__ == "__main__": + main() diff --git a/examples/clients/conformance-client/pyproject.toml b/examples/clients/conformance-client/pyproject.toml new file mode 100644 index 0000000000..7e8ce04a52 --- /dev/null +++ b/examples/clients/conformance-client/pyproject.toml @@ -0,0 +1,42 @@ +[project] +name = "mcp-conformance-client" +version = "0.1.0" +description = "Unified conformance test client for MCP" +requires-python = ">=3.10" +authors = [{ name = "Anthropic" }] +keywords = ["mcp", "client", "conformance", "testing"] +license = { text = "MIT" } +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", +] +dependencies = ["mcp", "httpx>=0.28.1"] + +[project.scripts] +mcp-conformance-client = "mcp_conformance_client:main" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["mcp_conformance_client"] + +[tool.pyright] +include = ["mcp_conformance_client"] +venvPath = "." +venv = ".venv" + +[tool.ruff.lint] +select = ["E", "F", "I"] +ignore = [] + +[tool.ruff] +line-length = 120 +target-version = "py310" + +[dependency-groups] +dev = ["pyright>=1.1.379", "pytest>=8.3.3", "ruff>=0.6.9"] diff --git a/scripts/run-server-conformance.sh b/scripts/run-server-conformance.sh new file mode 100755 index 0000000000..7c6b6933d1 --- /dev/null +++ b/scripts/run-server-conformance.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 server --url "$SERVER_URL" "$@" diff --git a/uv.lock b/uv.lock index 39f7a63609..e9de218fa9 100644 --- a/uv.lock +++ b/uv.lock @@ -10,6 +10,7 @@ resolution-markers = [ members = [ "mcp", "mcp-conformance-auth-client", + "mcp-conformance-client", "mcp-everything-server", "mcp-simple-auth", "mcp-simple-auth-client", @@ -851,6 +852,35 @@ dev = [ { name = "ruff", specifier = ">=0.6.9" }, ] +[[package]] +name = "mcp-conformance-client" +version = "0.1.0" +source = { editable = "examples/clients/conformance-client" } +dependencies = [ + { name = "httpx" }, + { name = "mcp" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pyright" }, + { name = "pytest" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "httpx", specifier = ">=0.28.1" }, + { name = "mcp", editable = "." }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "pyright", specifier = ">=1.1.379" }, + { name = "pytest", specifier = ">=8.3.3" }, + { name = "ruff", specifier = ">=0.6.9" }, +] + [[package]] name = "mcp-everything-server" version = "0.1.0" From 7ff7dd5b308efc53813b94c94b356a144308f81d Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Tue, 20 Jan 2026 17:26:19 +0000 Subject: [PATCH 02/10] Fix CI: use --package instead of --all-packages The --all-packages flag triggers a pre-existing issue where mcp-simple-chatbot is missing its README.md. Use --package to install only the specific packages needed for each job. --- .github/workflows/conformance.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml index 27f87905f8..1055253c3c 100644 --- a/.github/workflows/conformance.yml +++ b/.github/workflows/conformance.yml @@ -26,7 +26,7 @@ jobs: - uses: actions/setup-node@v4 with: node-version: 24 - - run: uv sync --frozen --all-extras --all-packages + - run: uv sync --frozen --all-extras --package mcp-everything-server - run: ./scripts/run-server-conformance.sh client-conformance: @@ -41,5 +41,5 @@ jobs: - uses: actions/setup-node@v4 with: node-version: 24 - - run: uv sync --frozen --all-extras --all-packages + - run: uv sync --frozen --all-extras --package mcp-conformance-client - run: npx @modelcontextprotocol/conformance client --command 'uv run --frozen mcp-conformance-client' From 6be97c9e99c1550734b705d7a6d4c5a482851f0a Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Tue, 20 Jan 2026 17:28:00 +0000 Subject: [PATCH 03/10] Add --suite all to client conformance The conformance package requires either --suite or --scenario. The newer version (0.1.10) supports --suite all. --- .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 1055253c3c..14520580f0 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-conformance-client - - run: npx @modelcontextprotocol/conformance client --command 'uv run --frozen mcp-conformance-client' + - run: npx @modelcontextprotocol/conformance client --command 'uv run --frozen mcp-conformance-client' --suite all From 9127055e52b38da68488d7eb34ef7270dda6940e Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Wed, 21 Jan 2026 11:49:19 +0000 Subject: [PATCH 04/10] Fix elicitation conformance tests and add results/ to gitignore - Extract shared default_elicitation_callback that applies schema defaults from ElicitRequestFormParams.requested_schema.properties.*.default - Add elicitation callback to _run_auth_session so all OAuth flows advertise elicitation capability - Call first available tool dynamically instead of hardcoded test-tool - Add results/ directory to .gitignore --- .gitignore | 1 + .../mcp_conformance_client/__init__.py | 51 +++++++++++++------ 2 files changed, 36 insertions(+), 16 deletions(-) diff --git a/.gitignore b/.gitignore index 2478cac4b3..de16995594 100644 --- a/.gitignore +++ b/.gitignore @@ -172,3 +172,4 @@ cython_debug/ # claude code .claude/ +results/ diff --git a/examples/clients/conformance-client/mcp_conformance_client/__init__.py b/examples/clients/conformance-client/mcp_conformance_client/__init__.py index 8b9a452663..59f056d50a 100644 --- a/examples/clients/conformance-client/mcp_conformance_client/__init__.py +++ b/examples/clients/conformance-client/mcp_conformance_client/__init__.py @@ -185,19 +185,33 @@ async def run_sse_retry(server_url: str) -> None: logger.debug(f"test_reconnection result: {result}") -@register("elicitation-sep1034-client-defaults") -async def run_elicitation_defaults(server_url: str) -> None: - """Connect with elicitation callback that accepts with empty content, call test tool.""" +async def default_elicitation_callback( + context: RequestContext[ClientSession, Any], # noqa: ARG001 + 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.requested_schema + 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) - async def elicitation_callback( - context: RequestContext[ClientSession, Any], # noqa: ARG001 - params: types.ElicitRequestParams, # noqa: ARG001 - ) -> types.ElicitResult | types.ErrorData: - """Accept elicitation with empty content (defaults).""" - return types.ElicitResult(action="accept", 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=elicitation_callback) as session: + 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", {}) @@ -281,18 +295,23 @@ async def _run_auth_session(server_url: str, oauth_auth: OAuthClientProvider) -> """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) as session: + 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]}") - try: - result = await session.call_tool("test-tool", {}) - logger.debug(f"Called test-tool, result: {result}") - except Exception as e: - logger.debug(f"Tool call result/error: {e}") + # 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") From 8d139d7a6b9086620232519da70912ea75de0485 Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Wed, 21 Jan 2026 14:18:10 +0000 Subject: [PATCH 05/10] Add CIMD client_metadata_url to conformance client The conformance client now passes a client_metadata_url to OAuthClientProvider, matching the TypeScript SDK's conformance client. This allows the SDK's existing CIMD support to activate when the server advertises client_id_metadata_document_supported=true, resolving the auth/basic-cimd warning. --- .../conformance-client/mcp_conformance_client/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/examples/clients/conformance-client/mcp_conformance_client/__init__.py b/examples/clients/conformance-client/mcp_conformance_client/__init__.py index 59f056d50a..3bd8e48548 100644 --- a/examples/clients/conformance-client/mcp_conformance_client/__init__.py +++ b/examples/clients/conformance-client/mcp_conformance_client/__init__.py @@ -286,6 +286,7 @@ async def run_auth_code_client(server_url: str) -> None: storage=InMemoryTokenStorage(), 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) From 70ab5cae0b0957b2fe4d25898e4702a410340576 Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Wed, 21 Jan 2026 14:35:11 +0000 Subject: [PATCH 06/10] Move conformance client/server scripts to scripts/conformance/ Address review feedback: the conformance client is CI test infrastructure, not a user-facing example. Move it to scripts/conformance/client.py as a standalone script and colocate it with the server script (renamed from scripts/run-server-conformance.sh to scripts/conformance/run-server.sh). This removes the mcp-conformance-client workspace member and its lockfile entry. The client script is now run directly via uv run python scripts/conformance/client.py. --- .github/workflows/conformance.yml | 6 +-- .../mcp_conformance_client/__main__.py | 6 --- .../clients/conformance-client/pyproject.toml | 42 ------------------- .../conformance/client.py | 3 +- .../run-server.sh} | 2 +- uv.lock | 30 ------------- 6 files changed, 6 insertions(+), 83 deletions(-) delete mode 100644 examples/clients/conformance-client/mcp_conformance_client/__main__.py delete mode 100644 examples/clients/conformance-client/pyproject.toml rename examples/clients/conformance-client/mcp_conformance_client/__init__.py => scripts/conformance/client.py (99%) rename scripts/{run-server-conformance.sh => conformance/run-server.sh} (97%) diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml index 14520580f0..3258612a04 100644 --- a/.github/workflows/conformance.yml +++ b/.github/workflows/conformance.yml @@ -27,7 +27,7 @@ jobs: with: node-version: 24 - run: uv sync --frozen --all-extras --package mcp-everything-server - - run: ./scripts/run-server-conformance.sh + - run: ./scripts/conformance/run-server.sh client-conformance: runs-on: ubuntu-latest @@ -41,5 +41,5 @@ jobs: - uses: actions/setup-node@v4 with: node-version: 24 - - run: uv sync --frozen --all-extras --package mcp-conformance-client - - run: npx @modelcontextprotocol/conformance client --command 'uv run --frozen mcp-conformance-client' --suite all + - run: uv sync --frozen --all-extras --package mcp + - run: npx @modelcontextprotocol/conformance client --command 'uv run --frozen python scripts/conformance/client.py' --suite all diff --git a/examples/clients/conformance-client/mcp_conformance_client/__main__.py b/examples/clients/conformance-client/mcp_conformance_client/__main__.py deleted file mode 100644 index 1b8f8acb09..0000000000 --- a/examples/clients/conformance-client/mcp_conformance_client/__main__.py +++ /dev/null @@ -1,6 +0,0 @@ -"""Allow running the module with python -m.""" - -from . import main - -if __name__ == "__main__": - main() diff --git a/examples/clients/conformance-client/pyproject.toml b/examples/clients/conformance-client/pyproject.toml deleted file mode 100644 index 7e8ce04a52..0000000000 --- a/examples/clients/conformance-client/pyproject.toml +++ /dev/null @@ -1,42 +0,0 @@ -[project] -name = "mcp-conformance-client" -version = "0.1.0" -description = "Unified conformance test client for MCP" -requires-python = ">=3.10" -authors = [{ name = "Anthropic" }] -keywords = ["mcp", "client", "conformance", "testing"] -license = { text = "MIT" } -classifiers = [ - "Development Status :: 4 - Beta", - "Intended Audience :: Developers", - "License :: OSI Approved :: MIT License", - "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.10", -] -dependencies = ["mcp", "httpx>=0.28.1"] - -[project.scripts] -mcp-conformance-client = "mcp_conformance_client:main" - -[build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" - -[tool.hatch.build.targets.wheel] -packages = ["mcp_conformance_client"] - -[tool.pyright] -include = ["mcp_conformance_client"] -venvPath = "." -venv = ".venv" - -[tool.ruff.lint] -select = ["E", "F", "I"] -ignore = [] - -[tool.ruff] -line-length = 120 -target-version = "py310" - -[dependency-groups] -dev = ["pyright>=1.1.379", "pytest>=8.3.3", "ruff>=0.6.9"] diff --git a/examples/clients/conformance-client/mcp_conformance_client/__init__.py b/scripts/conformance/client.py similarity index 99% rename from examples/clients/conformance-client/mcp_conformance_client/__init__.py rename to scripts/conformance/client.py index 3bd8e48548..dbbd5b64a1 100644 --- a/examples/clients/conformance-client/mcp_conformance_client/__init__.py +++ b/scripts/conformance/client.py @@ -29,6 +29,8 @@ 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 ( @@ -39,7 +41,6 @@ from mcp.client.streamable_http import streamable_http_client from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata, OAuthToken from mcp.shared.context import RequestContext -from pydantic import AnyUrl # Set up logging to stderr (stdout is for conformance test output) logging.basicConfig( diff --git a/scripts/run-server-conformance.sh b/scripts/conformance/run-server.sh similarity index 97% rename from scripts/run-server-conformance.sh rename to scripts/conformance/run-server.sh index 7c6b6933d1..5f7549b257 100755 --- a/scripts/run-server-conformance.sh +++ b/scripts/conformance/run-server.sh @@ -5,7 +5,7 @@ PORT="${PORT:-3001}" SERVER_URL="http://localhost:${PORT}/mcp" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -cd "$SCRIPT_DIR/.." +cd "$SCRIPT_DIR/../.." # Start everything-server uv run --frozen mcp-everything-server --port "$PORT" & diff --git a/uv.lock b/uv.lock index e9de218fa9..39f7a63609 100644 --- a/uv.lock +++ b/uv.lock @@ -10,7 +10,6 @@ resolution-markers = [ members = [ "mcp", "mcp-conformance-auth-client", - "mcp-conformance-client", "mcp-everything-server", "mcp-simple-auth", "mcp-simple-auth-client", @@ -852,35 +851,6 @@ dev = [ { name = "ruff", specifier = ">=0.6.9" }, ] -[[package]] -name = "mcp-conformance-client" -version = "0.1.0" -source = { editable = "examples/clients/conformance-client" } -dependencies = [ - { name = "httpx" }, - { name = "mcp" }, -] - -[package.dev-dependencies] -dev = [ - { name = "pyright" }, - { name = "pytest" }, - { name = "ruff" }, -] - -[package.metadata] -requires-dist = [ - { name = "httpx", specifier = ">=0.28.1" }, - { name = "mcp", editable = "." }, -] - -[package.metadata.requires-dev] -dev = [ - { name = "pyright", specifier = ">=1.1.379" }, - { name = "pytest", specifier = ">=8.3.3" }, - { name = "ruff", specifier = ">=0.6.9" }, -] - [[package]] name = "mcp-everything-server" version = "0.1.0" From bec2f20492da56f68756e1c4789a1b6c38fc7817 Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Wed, 21 Jan 2026 14:38:33 +0000 Subject: [PATCH 07/10] Move conformance-auth-client to scripts/conformance/ Same treatment as the unified conformance client: this is CI test infrastructure, not a user-facing example. Moved to scripts/conformance/auth-client.py and removed as a workspace member. --- .../clients/conformance-auth-client/README.md | 49 ------------------- .../mcp_conformance_auth_client/__main__.py | 6 --- .../conformance-auth-client/pyproject.toml | 43 ---------------- .../conformance/auth-client.py | 3 +- uv.lock | 30 ------------ 5 files changed, 2 insertions(+), 129 deletions(-) delete mode 100644 examples/clients/conformance-auth-client/README.md delete mode 100644 examples/clients/conformance-auth-client/mcp_conformance_auth_client/__main__.py delete mode 100644 examples/clients/conformance-auth-client/pyproject.toml rename examples/clients/conformance-auth-client/mcp_conformance_auth_client/__init__.py => scripts/conformance/auth-client.py (99%) diff --git a/examples/clients/conformance-auth-client/README.md b/examples/clients/conformance-auth-client/README.md deleted file mode 100644 index 312a992d0a..0000000000 --- a/examples/clients/conformance-auth-client/README.md +++ /dev/null @@ -1,49 +0,0 @@ -# MCP Conformance Auth Client - -A Python OAuth client designed for use with the MCP conformance test framework. - -## Overview - -This client implements OAuth authentication for MCP and is designed to work automatically with the conformance test framework without requiring user interaction. It programmatically fetches authorization URLs and extracts auth codes from redirects. - -## Installation - -```bash -cd examples/clients/conformance-auth-client -uv sync -``` - -## Usage with Conformance Tests - -Run the auth conformance tests against this Python client: - -```bash -# From the conformance repository -npx @modelcontextprotocol/conformance client \ - --command "uv run --directory /path/to/python-sdk/examples/clients/conformance-auth-client python -m mcp_conformance_auth_client" \ - --scenario auth/basic-dcr -``` - -Available auth test scenarios: - -- `auth/basic-dcr` - Tests OAuth Dynamic Client Registration flow -- `auth/basic-metadata-var1` - Tests OAuth with authorization metadata - -## How It Works - -Unlike interactive OAuth clients that open a browser for user authentication, this client: - -1. Receives the authorization URL from the OAuth provider -2. Makes an HTTP request to that URL directly (without following redirects) -3. Extracts the authorization code from the redirect response -4. Uses the code to complete the OAuth token exchange - -This allows the conformance test framework's mock OAuth server to automatically provide auth codes without human interaction. - -## Direct Usage - -You can also run the client directly: - -```bash -uv run python -m mcp_conformance_auth_client http://localhost:3000/mcp -``` diff --git a/examples/clients/conformance-auth-client/mcp_conformance_auth_client/__main__.py b/examples/clients/conformance-auth-client/mcp_conformance_auth_client/__main__.py deleted file mode 100644 index 1b8f8acb09..0000000000 --- a/examples/clients/conformance-auth-client/mcp_conformance_auth_client/__main__.py +++ /dev/null @@ -1,6 +0,0 @@ -"""Allow running the module with python -m.""" - -from . import main - -if __name__ == "__main__": - main() diff --git a/examples/clients/conformance-auth-client/pyproject.toml b/examples/clients/conformance-auth-client/pyproject.toml deleted file mode 100644 index 3d03b4d4a1..0000000000 --- a/examples/clients/conformance-auth-client/pyproject.toml +++ /dev/null @@ -1,43 +0,0 @@ -[project] -name = "mcp-conformance-auth-client" -version = "0.1.0" -description = "OAuth conformance test client for MCP" -readme = "README.md" -requires-python = ">=3.10" -authors = [{ name = "Anthropic" }] -keywords = ["mcp", "oauth", "client", "auth", "conformance", "testing"] -license = { text = "MIT" } -classifiers = [ - "Development Status :: 4 - Beta", - "Intended Audience :: Developers", - "License :: OSI Approved :: MIT License", - "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.10", -] -dependencies = ["mcp", "httpx>=0.28.1"] - -[project.scripts] -mcp-conformance-auth-client = "mcp_conformance_auth_client:main" - -[build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" - -[tool.hatch.build.targets.wheel] -packages = ["mcp_conformance_auth_client"] - -[tool.pyright] -include = ["mcp_conformance_auth_client"] -venvPath = "." -venv = ".venv" - -[tool.ruff.lint] -select = ["E", "F", "I"] -ignore = [] - -[tool.ruff] -line-length = 120 -target-version = "py310" - -[dependency-groups] -dev = ["pyright>=1.1.379", "pytest>=8.3.3", "ruff>=0.6.9"] diff --git a/examples/clients/conformance-auth-client/mcp_conformance_auth_client/__init__.py b/scripts/conformance/auth-client.py similarity index 99% rename from examples/clients/conformance-auth-client/mcp_conformance_auth_client/__init__.py rename to scripts/conformance/auth-client.py index 15d8274177..3281a012dc 100644 --- a/examples/clients/conformance-auth-client/mcp_conformance_auth_client/__init__.py +++ b/scripts/conformance/auth-client.py @@ -32,6 +32,8 @@ from urllib.parse import parse_qs, urlparse import httpx +from pydantic import AnyUrl + from mcp import ClientSession from mcp.client.auth import OAuthClientProvider, TokenStorage from mcp.client.auth.extensions.client_credentials import ( @@ -41,7 +43,6 @@ ) from mcp.client.streamable_http import streamable_http_client from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata, OAuthToken -from pydantic import AnyUrl def get_conformance_context() -> dict[str, Any]: diff --git a/uv.lock b/uv.lock index 39f7a63609..5d36da2e3a 100644 --- a/uv.lock +++ b/uv.lock @@ -9,7 +9,6 @@ resolution-markers = [ [manifest] members = [ "mcp", - "mcp-conformance-auth-client", "mcp-everything-server", "mcp-simple-auth", "mcp-simple-auth-client", @@ -822,35 +821,6 @@ docs = [ { name = "mkdocstrings-python", specifier = ">=2.0.1" }, ] -[[package]] -name = "mcp-conformance-auth-client" -version = "0.1.0" -source = { editable = "examples/clients/conformance-auth-client" } -dependencies = [ - { name = "httpx" }, - { name = "mcp" }, -] - -[package.dev-dependencies] -dev = [ - { name = "pyright" }, - { name = "pytest" }, - { name = "ruff" }, -] - -[package.metadata] -requires-dist = [ - { name = "httpx", specifier = ">=0.28.1" }, - { name = "mcp", editable = "." }, -] - -[package.metadata.requires-dev] -dev = [ - { name = "pyright", specifier = ">=1.1.379" }, - { name = "pytest", specifier = ">=8.3.3" }, - { name = "ruff", specifier = ">=0.6.9" }, -] - [[package]] name = "mcp-everything-server" version = "0.1.0" From 12c7936413f6b711a0ed9a86298696c82d331efc Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Wed, 21 Jan 2026 15:10:19 +0000 Subject: [PATCH 08/10] Pin @modelcontextprotocol/conformance to 0.1.10 --- .github/workflows/conformance.yml | 2 +- scripts/conformance/run-server.sh | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml index 3258612a04..b265554181 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 client --command 'uv run --frozen python scripts/conformance/client.py' --suite all + - run: npx @modelcontextprotocol/conformance@0.1.10 client --command 'uv run --frozen python scripts/conformance/client.py' --suite all diff --git a/scripts/conformance/run-server.sh b/scripts/conformance/run-server.sh index 5f7549b257..aca5e7c899 100755 --- a/scripts/conformance/run-server.sh +++ b/scripts/conformance/run-server.sh @@ -27,4 +27,4 @@ done echo "Server ready at $SERVER_URL" # Run conformance tests -npx @modelcontextprotocol/conformance server --url "$SERVER_URL" "$@" +npx @modelcontextprotocol/conformance@0.1.10 server --url "$SERVER_URL" "$@" From f7c1b76559292b473c1c2223475fab88e4f9eb1b Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Wed, 21 Jan 2026 15:11:57 +0000 Subject: [PATCH 09/10] Pin GitHub Actions to commit SHAs Match the pinning pattern used in shared.yml for supply chain security. --- .github/workflows/conformance.yml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml index b265554181..e1acb38f5b 100644 --- a/.github/workflows/conformance.yml +++ b/.github/workflows/conformance.yml @@ -18,12 +18,12 @@ jobs: runs-on: ubuntu-latest continue-on-error: true steps: - - uses: actions/checkout@v4 - - uses: astral-sh/setup-uv@v7 + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + - uses: astral-sh/setup-uv@61cb8a9741eeb8a550a1b8544337180c0fc8476b # v7.2.0 with: enable-cache: true version: 0.9.5 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: 24 - run: uv sync --frozen --all-extras --package mcp-everything-server @@ -33,12 +33,12 @@ jobs: runs-on: ubuntu-latest continue-on-error: true steps: - - uses: actions/checkout@v4 - - uses: astral-sh/setup-uv@v7 + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + - uses: astral-sh/setup-uv@61cb8a9741eeb8a550a1b8544337180c0fc8476b # v7.2.0 with: enable-cache: true version: 0.9.5 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: 24 - run: uv sync --frozen --all-extras --package mcp From f0252241a7de5451f3c983b6e0c38b57ad56f022 Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Wed, 21 Jan 2026 15:40:28 +0000 Subject: [PATCH 10/10] Move conformance scripts to .github/actions/conformance/ - Move client.py and run-server.sh to .github/actions/conformance/ - Remove redundant auth-client.py (superseded by client.py) - Drop try/except wrappers in main() - unhandled exceptions already exit non-zero with tracebacks - Update workflow paths accordingly --- .../actions}/conformance/client.py | 27 +- .../actions}/conformance/run-server.sh | 2 +- .github/workflows/conformance.yml | 4 +- scripts/conformance/auth-client.py | 307 ------------------ 4 files changed, 10 insertions(+), 330 deletions(-) rename {scripts => .github/actions}/conformance/client.py (94%) rename {scripts => .github/actions}/conformance/run-server.sh (96%) delete mode 100644 scripts/conformance/auth-client.py diff --git a/scripts/conformance/client.py b/.github/actions/conformance/client.py similarity index 94% rename from scripts/conformance/client.py rename to .github/actions/conformance/client.py index dbbd5b64a1..7ca88110a7 100644 --- a/scripts/conformance/client.py +++ b/.github/actions/conformance/client.py @@ -325,34 +325,21 @@ def main() -> None: sys.exit(1) server_url = sys.argv[1] - - # Check for explicit scenario override (for manual testing) scenario = os.environ.get("MCP_CONFORMANCE_SCENARIO") if scenario: logger.debug(f"Running explicit scenario '{scenario}' against {server_url}") handler = HANDLERS.get(scenario) - try: - 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) - except Exception: - logger.exception("Client failed") + 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: - # No explicit scenario - run default auth flow - # The conformance framework tests different behaviors by configuring - # its mock server; our client just needs to handle OAuth properly logger.debug(f"Running default auth flow against {server_url}") - try: - asyncio.run(run_auth_code_client(server_url)) - except Exception: - logger.exception("Client failed") - sys.exit(1) + asyncio.run(run_auth_code_client(server_url)) if __name__ == "__main__": diff --git a/scripts/conformance/run-server.sh b/.github/actions/conformance/run-server.sh similarity index 96% rename from scripts/conformance/run-server.sh rename to .github/actions/conformance/run-server.sh index aca5e7c899..01af136120 100755 --- a/scripts/conformance/run-server.sh +++ b/.github/actions/conformance/run-server.sh @@ -5,7 +5,7 @@ PORT="${PORT:-3001}" SERVER_URL="http://localhost:${PORT}/mcp" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -cd "$SCRIPT_DIR/../.." +cd "$SCRIPT_DIR/../../.." # Start everything-server uv run --frozen mcp-everything-server --port "$PORT" & diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml index e1acb38f5b..248e5bf6ac 100644 --- a/.github/workflows/conformance.yml +++ b/.github/workflows/conformance.yml @@ -27,7 +27,7 @@ jobs: with: node-version: 24 - run: uv sync --frozen --all-extras --package mcp-everything-server - - run: ./scripts/conformance/run-server.sh + - run: ./.github/actions/conformance/run-server.sh client-conformance: runs-on: ubuntu-latest @@ -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 scripts/conformance/client.py' --suite all + - run: npx @modelcontextprotocol/conformance@0.1.10 client --command 'uv run --frozen python .github/actions/conformance/client.py' --suite all diff --git a/scripts/conformance/auth-client.py b/scripts/conformance/auth-client.py deleted file mode 100644 index 3281a012dc..0000000000 --- a/scripts/conformance/auth-client.py +++ /dev/null @@ -1,307 +0,0 @@ -#!/usr/bin/env python3 -"""MCP OAuth conformance test client. - -This client is designed to work with the MCP conformance test framework. -It automatically handles OAuth flows without user interaction by programmatically -fetching the authorization URL and extracting the auth code from the redirect. - -Usage: - python -m mcp_conformance_auth_client - -Environment Variables: - MCP_CONFORMANCE_CONTEXT - JSON object containing test credentials: - { - "client_id": "...", - "client_secret": "...", # For client_secret_basic flow - "private_key_pem": "...", # For private_key_jwt flow - "signing_algorithm": "ES256" # Optional, defaults to ES256 - } - -Scenarios: - auth/* - Authorization code flow scenarios (default behavior) - auth/client-credentials-jwt - Client credentials with JWT authentication (SEP-1046) - auth/client-credentials-basic - Client credentials with client_secret_basic -""" - -import asyncio -import json -import logging -import os -import sys -from typing import Any, cast -from urllib.parse import parse_qs, urlparse - -import httpx -from pydantic import AnyUrl - -from mcp import ClientSession -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 - - -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 - - -# 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__) - - -class InMemoryTokenStorage(TokenStorage): - """Simple in-memory token storage for conformance testing.""" - - def __init__(self): - 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. - - This mimics the behavior of the TypeScript ConformanceOAuthProvider. - """ - - def __init__(self): - 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. - - The conformance test server returns a redirect with the auth code, - so we can capture it programmatically. - """ - logger.debug(f"Fetching authorization URL: {authorization_url}") - - async with httpx.AsyncClient() as client: - response = await client.get( - authorization_url, - follow_redirects=False, # Don't follow redirects automatically - ) - - # Check for redirect response - 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, then clear them for potential reuse.""" - if self._auth_code is None: - raise RuntimeError("No authorization code available - was handle_redirect called?") - auth_code = self._auth_code - state = self._state - # Clear the stored values so the next auth flow gets fresh ones - self._auth_code = None - self._state = None - return auth_code, state - - -async def run_authorization_code_client(server_url: str) -> None: - """Run the conformance test client with authorization code flow. - - This function: - 1. Connects to the MCP server with OAuth authorization code flow - 2. Initializes the session - 3. Lists available tools - 4. Calls a test tool - """ - logger.debug(f"Starting conformance auth client (authorization_code) for {server_url}") - - # Create callback handler that will automatically fetch auth codes - callback_handler = ConformanceOAuthCallbackHandler() - - # Create OAuth authentication handler - oauth_auth = OAuthClientProvider( - server_url=server_url, - client_metadata=OAuthClientMetadata( - client_name="conformance-auth-client", - redirect_uris=[AnyUrl("http://localhost:3000/callback")], - grant_types=["authorization_code", "refresh_token"], - response_types=["code"], - ), - storage=InMemoryTokenStorage(), - redirect_handler=callback_handler.handle_redirect, - callback_handler=callback_handler.handle_callback, - ) - - await _run_session(server_url, oauth_auth) - - -async def run_client_credentials_jwt_client(server_url: str) -> None: - """Run the conformance test client with client credentials flow using private_key_jwt (SEP-1046). - - This function: - 1. Connects to the MCP server with OAuth client_credentials grant - 2. Uses private_key_jwt authentication with credentials from MCP_CONFORMANCE_CONTEXT - 3. Initializes the session - 4. Lists available tools - 5. Calls a test tool - """ - logger.debug(f"Starting conformance auth client (client_credentials_jwt) for {server_url}") - - # Load credentials from environment - 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'") - - # Create JWT parameters for SDK-signed assertions - jwt_params = SignedJWTParameters( - issuer=client_id, - subject=client_id, - signing_algorithm=signing_algorithm, - signing_key=private_key_pem, - ) - - # Create OAuth provider for client_credentials with private_key_jwt - oauth_auth = PrivateKeyJWTOAuthProvider( - server_url=server_url, - storage=InMemoryTokenStorage(), - client_id=client_id, - assertion_provider=jwt_params.create_assertion_provider(), - ) - - await _run_session(server_url, oauth_auth) - - -async def run_client_credentials_basic_client(server_url: str) -> None: - """Run the conformance test client with client credentials flow using client_secret_basic. - - This function: - 1. Connects to the MCP server with OAuth client_credentials grant - 2. Uses client_secret_basic authentication with credentials from MCP_CONFORMANCE_CONTEXT - 3. Initializes the session - 4. Lists available tools - 5. Calls a test tool - """ - logger.debug(f"Starting conformance auth client (client_credentials_basic) for {server_url}") - - # Load credentials from environment - 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'") - - # Create OAuth provider for client_credentials with client_secret_basic - 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_session(server_url, oauth_auth) - - -async def _run_session(server_url: str, oauth_auth: OAuthClientProvider) -> None: - """Common session logic for all OAuth flows.""" - # Connect using streamable HTTP transport with OAuth - 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) as session: - # Initialize the session - await session.initialize() - logger.debug("Successfully connected and initialized MCP session") - - # List tools - tools_result = await session.list_tools() - logger.debug(f"Listed tools: {[t.name for t in tools_result.tools]}") - - # Call test tool (expected by conformance tests) - try: - result = await session.call_tool("test-tool", {}) - logger.debug(f"Called test-tool, 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 auth client.""" - if len(sys.argv) != 3: - print(f"Usage: {sys.argv[0]} ", file=sys.stderr) - print("", file=sys.stderr) - print("Scenarios:", file=sys.stderr) - print(" auth/* - Authorization code flow (default)", file=sys.stderr) - print(" auth/client-credentials-jwt - Client credentials with JWT auth (SEP-1046)", file=sys.stderr) - print(" auth/client-credentials-basic - Client credentials with client_secret_basic", file=sys.stderr) - sys.exit(1) - - scenario = sys.argv[1] - server_url = sys.argv[2] - - try: - if scenario == "auth/client-credentials-jwt": - asyncio.run(run_client_credentials_jwt_client(server_url)) - elif scenario == "auth/client-credentials-basic": - asyncio.run(run_client_credentials_basic_client(server_url)) - else: - # Default to authorization code flow for all other auth/* scenarios - asyncio.run(run_authorization_code_client(server_url)) - except Exception: - logger.exception("Client failed") - sys.exit(1) - - -if __name__ == "__main__": - main()