Skip to content

Commit de7f8c4

Browse files
committed
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)
1 parent bcb07c2 commit de7f8c4

File tree

6 files changed

+491
-0
lines changed

6 files changed

+491
-0
lines changed

.github/workflows/conformance.yml

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
name: Conformance Tests
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
workflow_dispatch:
8+
9+
concurrency:
10+
group: conformance-${{ github.ref }}
11+
cancel-in-progress: true
12+
13+
permissions:
14+
contents: read
15+
16+
jobs:
17+
server-conformance:
18+
runs-on: ubuntu-latest
19+
continue-on-error: true
20+
steps:
21+
- uses: actions/checkout@v4
22+
- uses: astral-sh/setup-uv@v7
23+
with:
24+
enable-cache: true
25+
version: 0.9.5
26+
- uses: actions/setup-node@v4
27+
with:
28+
node-version: 24
29+
- run: uv sync --frozen --all-extras --all-packages
30+
- run: ./scripts/run-server-conformance.sh
31+
32+
client-conformance:
33+
runs-on: ubuntu-latest
34+
continue-on-error: true
35+
steps:
36+
- uses: actions/checkout@v4
37+
- uses: astral-sh/setup-uv@v7
38+
with:
39+
enable-cache: true
40+
version: 0.9.5
41+
- uses: actions/setup-node@v4
42+
with:
43+
node-version: 24
44+
- run: uv sync --frozen --all-extras --all-packages
45+
- run: npx @modelcontextprotocol/conformance client --command 'uv run --frozen mcp-conformance-client'
Lines changed: 338 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,338 @@
1+
"""MCP unified conformance test client.
2+
3+
This client is designed to work with the @modelcontextprotocol/conformance npm package.
4+
It handles all conformance test scenarios via environment variables and CLI arguments.
5+
6+
Contract:
7+
- MCP_CONFORMANCE_SCENARIO env var -> scenario name
8+
- MCP_CONFORMANCE_CONTEXT env var -> optional JSON (for client-credentials scenarios)
9+
- Server URL as last CLI argument (sys.argv[1])
10+
- Must exit 0 within 30 seconds
11+
12+
Scenarios:
13+
initialize - Connect, initialize, list tools, close
14+
tools_call - Connect, call add_numbers(a=5, b=3), close
15+
sse-retry - Connect, call test_reconnection, close
16+
elicitation-sep1034-client-defaults - Elicitation with default accept callback
17+
auth/client-credentials-jwt - Client credentials with private_key_jwt
18+
auth/client-credentials-basic - Client credentials with client_secret_basic
19+
auth/* - Authorization code flow (default for auth scenarios)
20+
"""
21+
22+
import asyncio
23+
import json
24+
import logging
25+
import os
26+
import sys
27+
from collections.abc import Callable, Coroutine
28+
from typing import Any, cast
29+
from urllib.parse import parse_qs, urlparse
30+
31+
import httpx
32+
from mcp import ClientSession, types
33+
from mcp.client.auth import OAuthClientProvider, TokenStorage
34+
from mcp.client.auth.extensions.client_credentials import (
35+
ClientCredentialsOAuthProvider,
36+
PrivateKeyJWTOAuthProvider,
37+
SignedJWTParameters,
38+
)
39+
from mcp.client.streamable_http import streamable_http_client
40+
from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata, OAuthToken
41+
from mcp.shared.context import RequestContext
42+
from pydantic import AnyUrl
43+
44+
# Set up logging to stderr (stdout is for conformance test output)
45+
logging.basicConfig(
46+
level=logging.DEBUG,
47+
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
48+
stream=sys.stderr,
49+
)
50+
logger = logging.getLogger(__name__)
51+
52+
# Type for async scenario handler functions
53+
ScenarioHandler = Callable[[str], Coroutine[Any, None, None]]
54+
55+
# Registry of scenario handlers
56+
HANDLERS: dict[str, ScenarioHandler] = {}
57+
58+
59+
def register(name: str) -> Callable[[ScenarioHandler], ScenarioHandler]:
60+
"""Register a scenario handler."""
61+
62+
def decorator(fn: ScenarioHandler) -> ScenarioHandler:
63+
HANDLERS[name] = fn
64+
return fn
65+
66+
return decorator
67+
68+
69+
def get_conformance_context() -> dict[str, Any]:
70+
"""Load conformance test context from MCP_CONFORMANCE_CONTEXT environment variable."""
71+
context_json = os.environ.get("MCP_CONFORMANCE_CONTEXT")
72+
if not context_json:
73+
raise RuntimeError(
74+
"MCP_CONFORMANCE_CONTEXT environment variable not set. "
75+
"Expected JSON with client_id, client_secret, and/or private_key_pem."
76+
)
77+
try:
78+
return json.loads(context_json)
79+
except json.JSONDecodeError as e:
80+
raise RuntimeError(f"Failed to parse MCP_CONFORMANCE_CONTEXT as JSON: {e}") from e
81+
82+
83+
class InMemoryTokenStorage(TokenStorage):
84+
"""Simple in-memory token storage for conformance testing."""
85+
86+
def __init__(self) -> None:
87+
self._tokens: OAuthToken | None = None
88+
self._client_info: OAuthClientInformationFull | None = None
89+
90+
async def get_tokens(self) -> OAuthToken | None:
91+
return self._tokens
92+
93+
async def set_tokens(self, tokens: OAuthToken) -> None:
94+
self._tokens = tokens
95+
96+
async def get_client_info(self) -> OAuthClientInformationFull | None:
97+
return self._client_info
98+
99+
async def set_client_info(self, client_info: OAuthClientInformationFull) -> None:
100+
self._client_info = client_info
101+
102+
103+
class ConformanceOAuthCallbackHandler:
104+
"""OAuth callback handler that automatically fetches the authorization URL
105+
and extracts the auth code, without requiring user interaction.
106+
"""
107+
108+
def __init__(self) -> None:
109+
self._auth_code: str | None = None
110+
self._state: str | None = None
111+
112+
async def handle_redirect(self, authorization_url: str) -> None:
113+
"""Fetch the authorization URL and extract the auth code from the redirect."""
114+
logger.debug(f"Fetching authorization URL: {authorization_url}")
115+
116+
async with httpx.AsyncClient() as client:
117+
response = await client.get(
118+
authorization_url,
119+
follow_redirects=False,
120+
)
121+
122+
if response.status_code in (301, 302, 303, 307, 308):
123+
location = cast(str, response.headers.get("location"))
124+
if location:
125+
redirect_url = urlparse(location)
126+
query_params: dict[str, list[str]] = parse_qs(redirect_url.query)
127+
128+
if "code" in query_params:
129+
self._auth_code = query_params["code"][0]
130+
state_values = query_params.get("state")
131+
self._state = state_values[0] if state_values else None
132+
logger.debug(f"Got auth code from redirect: {self._auth_code[:10]}...")
133+
return
134+
else:
135+
raise RuntimeError(f"No auth code in redirect URL: {location}")
136+
else:
137+
raise RuntimeError(f"No redirect location received from {authorization_url}")
138+
else:
139+
raise RuntimeError(f"Expected redirect response, got {response.status_code} from {authorization_url}")
140+
141+
async def handle_callback(self) -> tuple[str, str | None]:
142+
"""Return the captured auth code and state."""
143+
if self._auth_code is None:
144+
raise RuntimeError("No authorization code available - was handle_redirect called?")
145+
auth_code = self._auth_code
146+
state = self._state
147+
self._auth_code = None
148+
self._state = None
149+
return auth_code, state
150+
151+
152+
# --- Scenario Handlers ---
153+
154+
155+
@register("initialize")
156+
async def run_initialize(server_url: str) -> None:
157+
"""Connect, initialize, list tools, close."""
158+
async with streamable_http_client(url=server_url) as (read_stream, write_stream, _):
159+
async with ClientSession(read_stream, write_stream) as session:
160+
await session.initialize()
161+
logger.debug("Initialized successfully")
162+
await session.list_tools()
163+
logger.debug("Listed tools successfully")
164+
165+
166+
@register("tools_call")
167+
async def run_tools_call(server_url: str) -> None:
168+
"""Connect, initialize, list tools, call add_numbers(a=5, b=3), close."""
169+
async with streamable_http_client(url=server_url) as (read_stream, write_stream, _):
170+
async with ClientSession(read_stream, write_stream) as session:
171+
await session.initialize()
172+
await session.list_tools()
173+
result = await session.call_tool("add_numbers", {"a": 5, "b": 3})
174+
logger.debug(f"add_numbers result: {result}")
175+
176+
177+
@register("sse-retry")
178+
async def run_sse_retry(server_url: str) -> None:
179+
"""Connect, initialize, list tools, call test_reconnection, close."""
180+
async with streamable_http_client(url=server_url) as (read_stream, write_stream, _):
181+
async with ClientSession(read_stream, write_stream) as session:
182+
await session.initialize()
183+
await session.list_tools()
184+
result = await session.call_tool("test_reconnection", {})
185+
logger.debug(f"test_reconnection result: {result}")
186+
187+
188+
@register("elicitation-sep1034-client-defaults")
189+
async def run_elicitation_defaults(server_url: str) -> None:
190+
"""Connect with elicitation callback that accepts with empty content, call test tool."""
191+
192+
async def elicitation_callback(
193+
context: RequestContext[ClientSession, Any], # noqa: ARG001
194+
params: types.ElicitRequestParams, # noqa: ARG001
195+
) -> types.ElicitResult | types.ErrorData:
196+
"""Accept elicitation with empty content (defaults)."""
197+
return types.ElicitResult(action="accept", content={})
198+
199+
async with streamable_http_client(url=server_url) as (read_stream, write_stream, _):
200+
async with ClientSession(read_stream, write_stream, elicitation_callback=elicitation_callback) as session:
201+
await session.initialize()
202+
await session.list_tools()
203+
result = await session.call_tool("test_client_elicitation_defaults", {})
204+
logger.debug(f"test_client_elicitation_defaults result: {result}")
205+
206+
207+
@register("auth/client-credentials-jwt")
208+
async def run_client_credentials_jwt(server_url: str) -> None:
209+
"""Client credentials flow with private_key_jwt authentication."""
210+
context = get_conformance_context()
211+
client_id = context.get("client_id")
212+
private_key_pem = context.get("private_key_pem")
213+
signing_algorithm = context.get("signing_algorithm", "ES256")
214+
215+
if not client_id:
216+
raise RuntimeError("MCP_CONFORMANCE_CONTEXT missing 'client_id'")
217+
if not private_key_pem:
218+
raise RuntimeError("MCP_CONFORMANCE_CONTEXT missing 'private_key_pem'")
219+
220+
jwt_params = SignedJWTParameters(
221+
issuer=client_id,
222+
subject=client_id,
223+
signing_algorithm=signing_algorithm,
224+
signing_key=private_key_pem,
225+
)
226+
227+
oauth_auth = PrivateKeyJWTOAuthProvider(
228+
server_url=server_url,
229+
storage=InMemoryTokenStorage(),
230+
client_id=client_id,
231+
assertion_provider=jwt_params.create_assertion_provider(),
232+
)
233+
234+
await _run_auth_session(server_url, oauth_auth)
235+
236+
237+
@register("auth/client-credentials-basic")
238+
async def run_client_credentials_basic(server_url: str) -> None:
239+
"""Client credentials flow with client_secret_basic authentication."""
240+
context = get_conformance_context()
241+
client_id = context.get("client_id")
242+
client_secret = context.get("client_secret")
243+
244+
if not client_id:
245+
raise RuntimeError("MCP_CONFORMANCE_CONTEXT missing 'client_id'")
246+
if not client_secret:
247+
raise RuntimeError("MCP_CONFORMANCE_CONTEXT missing 'client_secret'")
248+
249+
oauth_auth = ClientCredentialsOAuthProvider(
250+
server_url=server_url,
251+
storage=InMemoryTokenStorage(),
252+
client_id=client_id,
253+
client_secret=client_secret,
254+
token_endpoint_auth_method="client_secret_basic",
255+
)
256+
257+
await _run_auth_session(server_url, oauth_auth)
258+
259+
260+
async def run_auth_code_client(server_url: str) -> None:
261+
"""Authorization code flow (default for auth/* scenarios)."""
262+
callback_handler = ConformanceOAuthCallbackHandler()
263+
264+
oauth_auth = OAuthClientProvider(
265+
server_url=server_url,
266+
client_metadata=OAuthClientMetadata(
267+
client_name="conformance-client",
268+
redirect_uris=[AnyUrl("http://localhost:3000/callback")],
269+
grant_types=["authorization_code", "refresh_token"],
270+
response_types=["code"],
271+
),
272+
storage=InMemoryTokenStorage(),
273+
redirect_handler=callback_handler.handle_redirect,
274+
callback_handler=callback_handler.handle_callback,
275+
)
276+
277+
await _run_auth_session(server_url, oauth_auth)
278+
279+
280+
async def _run_auth_session(server_url: str, oauth_auth: OAuthClientProvider) -> None:
281+
"""Common session logic for all OAuth flows."""
282+
client = httpx.AsyncClient(auth=oauth_auth, timeout=30.0)
283+
async with streamable_http_client(url=server_url, http_client=client) as (read_stream, write_stream, _):
284+
async with ClientSession(read_stream, write_stream) as session:
285+
await session.initialize()
286+
logger.debug("Initialized successfully")
287+
288+
tools_result = await session.list_tools()
289+
logger.debug(f"Listed tools: {[t.name for t in tools_result.tools]}")
290+
291+
try:
292+
result = await session.call_tool("test-tool", {})
293+
logger.debug(f"Called test-tool, result: {result}")
294+
except Exception as e:
295+
logger.debug(f"Tool call result/error: {e}")
296+
297+
logger.debug("Connection closed successfully")
298+
299+
300+
def main() -> None:
301+
"""Main entry point for the conformance client."""
302+
if len(sys.argv) < 2:
303+
print(f"Usage: {sys.argv[0]} <server-url>", file=sys.stderr)
304+
sys.exit(1)
305+
306+
server_url = sys.argv[1]
307+
308+
# Check for explicit scenario override (for manual testing)
309+
scenario = os.environ.get("MCP_CONFORMANCE_SCENARIO")
310+
311+
if scenario:
312+
logger.debug(f"Running explicit scenario '{scenario}' against {server_url}")
313+
handler = HANDLERS.get(scenario)
314+
try:
315+
if handler:
316+
asyncio.run(handler(server_url))
317+
elif scenario.startswith("auth/"):
318+
asyncio.run(run_auth_code_client(server_url))
319+
else:
320+
print(f"Unknown scenario: {scenario}", file=sys.stderr)
321+
sys.exit(1)
322+
except Exception:
323+
logger.exception("Client failed")
324+
sys.exit(1)
325+
else:
326+
# No explicit scenario - run default auth flow
327+
# The conformance framework tests different behaviors by configuring
328+
# its mock server; our client just needs to handle OAuth properly
329+
logger.debug(f"Running default auth flow against {server_url}")
330+
try:
331+
asyncio.run(run_auth_code_client(server_url))
332+
except Exception:
333+
logger.exception("Client failed")
334+
sys.exit(1)
335+
336+
337+
if __name__ == "__main__":
338+
main()
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
"""Allow running the module with python -m."""
2+
3+
from . import main
4+
5+
if __name__ == "__main__":
6+
main()

0 commit comments

Comments
 (0)