Skip to content

Commit 213cf99

Browse files
authored
Add conformance testing CI pipeline (#1915)
1 parent 3bcdc17 commit 213cf99

File tree

9 files changed

+422
-434
lines changed

9 files changed

+422
-434
lines changed
Lines changed: 346 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,346 @@
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 pydantic import AnyUrl
33+
34+
from mcp import ClientSession, types
35+
from mcp.client.auth import OAuthClientProvider, TokenStorage
36+
from mcp.client.auth.extensions.client_credentials import (
37+
ClientCredentialsOAuthProvider,
38+
PrivateKeyJWTOAuthProvider,
39+
SignedJWTParameters,
40+
)
41+
from mcp.client.streamable_http import streamable_http_client
42+
from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata, OAuthToken
43+
from mcp.shared.context import RequestContext
44+
45+
# Set up logging to stderr (stdout is for conformance test output)
46+
logging.basicConfig(
47+
level=logging.DEBUG,
48+
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
49+
stream=sys.stderr,
50+
)
51+
logger = logging.getLogger(__name__)
52+
53+
# Type for async scenario handler functions
54+
ScenarioHandler = Callable[[str], Coroutine[Any, None, None]]
55+
56+
# Registry of scenario handlers
57+
HANDLERS: dict[str, ScenarioHandler] = {}
58+
59+
60+
def register(name: str) -> Callable[[ScenarioHandler], ScenarioHandler]:
61+
"""Register a scenario handler."""
62+
63+
def decorator(fn: ScenarioHandler) -> ScenarioHandler:
64+
HANDLERS[name] = fn
65+
return fn
66+
67+
return decorator
68+
69+
70+
def get_conformance_context() -> dict[str, Any]:
71+
"""Load conformance test context from MCP_CONFORMANCE_CONTEXT environment variable."""
72+
context_json = os.environ.get("MCP_CONFORMANCE_CONTEXT")
73+
if not context_json:
74+
raise RuntimeError(
75+
"MCP_CONFORMANCE_CONTEXT environment variable not set. "
76+
"Expected JSON with client_id, client_secret, and/or private_key_pem."
77+
)
78+
try:
79+
return json.loads(context_json)
80+
except json.JSONDecodeError as e:
81+
raise RuntimeError(f"Failed to parse MCP_CONFORMANCE_CONTEXT as JSON: {e}") from e
82+
83+
84+
class InMemoryTokenStorage(TokenStorage):
85+
"""Simple in-memory token storage for conformance testing."""
86+
87+
def __init__(self) -> None:
88+
self._tokens: OAuthToken | None = None
89+
self._client_info: OAuthClientInformationFull | None = None
90+
91+
async def get_tokens(self) -> OAuthToken | None:
92+
return self._tokens
93+
94+
async def set_tokens(self, tokens: OAuthToken) -> None:
95+
self._tokens = tokens
96+
97+
async def get_client_info(self) -> OAuthClientInformationFull | None:
98+
return self._client_info
99+
100+
async def set_client_info(self, client_info: OAuthClientInformationFull) -> None:
101+
self._client_info = client_info
102+
103+
104+
class ConformanceOAuthCallbackHandler:
105+
"""OAuth callback handler that automatically fetches the authorization URL
106+
and extracts the auth code, without requiring user interaction.
107+
"""
108+
109+
def __init__(self) -> None:
110+
self._auth_code: str | None = None
111+
self._state: str | None = None
112+
113+
async def handle_redirect(self, authorization_url: str) -> None:
114+
"""Fetch the authorization URL and extract the auth code from the redirect."""
115+
logger.debug(f"Fetching authorization URL: {authorization_url}")
116+
117+
async with httpx.AsyncClient() as client:
118+
response = await client.get(
119+
authorization_url,
120+
follow_redirects=False,
121+
)
122+
123+
if response.status_code in (301, 302, 303, 307, 308):
124+
location = cast(str, response.headers.get("location"))
125+
if location:
126+
redirect_url = urlparse(location)
127+
query_params: dict[str, list[str]] = parse_qs(redirect_url.query)
128+
129+
if "code" in query_params:
130+
self._auth_code = query_params["code"][0]
131+
state_values = query_params.get("state")
132+
self._state = state_values[0] if state_values else None
133+
logger.debug(f"Got auth code from redirect: {self._auth_code[:10]}...")
134+
return
135+
else:
136+
raise RuntimeError(f"No auth code in redirect URL: {location}")
137+
else:
138+
raise RuntimeError(f"No redirect location received from {authorization_url}")
139+
else:
140+
raise RuntimeError(f"Expected redirect response, got {response.status_code} from {authorization_url}")
141+
142+
async def handle_callback(self) -> tuple[str, str | None]:
143+
"""Return the captured auth code and state."""
144+
if self._auth_code is None:
145+
raise RuntimeError("No authorization code available - was handle_redirect called?")
146+
auth_code = self._auth_code
147+
state = self._state
148+
self._auth_code = None
149+
self._state = None
150+
return auth_code, state
151+
152+
153+
# --- Scenario Handlers ---
154+
155+
156+
@register("initialize")
157+
async def run_initialize(server_url: str) -> None:
158+
"""Connect, initialize, list tools, close."""
159+
async with streamable_http_client(url=server_url) as (read_stream, write_stream, _):
160+
async with ClientSession(read_stream, write_stream) as session:
161+
await session.initialize()
162+
logger.debug("Initialized successfully")
163+
await session.list_tools()
164+
logger.debug("Listed tools successfully")
165+
166+
167+
@register("tools_call")
168+
async def run_tools_call(server_url: str) -> None:
169+
"""Connect, initialize, list tools, call add_numbers(a=5, b=3), close."""
170+
async with streamable_http_client(url=server_url) as (read_stream, write_stream, _):
171+
async with ClientSession(read_stream, write_stream) as session:
172+
await session.initialize()
173+
await session.list_tools()
174+
result = await session.call_tool("add_numbers", {"a": 5, "b": 3})
175+
logger.debug(f"add_numbers result: {result}")
176+
177+
178+
@register("sse-retry")
179+
async def run_sse_retry(server_url: str) -> None:
180+
"""Connect, initialize, list tools, call test_reconnection, close."""
181+
async with streamable_http_client(url=server_url) as (read_stream, write_stream, _):
182+
async with ClientSession(read_stream, write_stream) as session:
183+
await session.initialize()
184+
await session.list_tools()
185+
result = await session.call_tool("test_reconnection", {})
186+
logger.debug(f"test_reconnection result: {result}")
187+
188+
189+
async def default_elicitation_callback(
190+
context: RequestContext[ClientSession, Any], # noqa: ARG001
191+
params: types.ElicitRequestParams,
192+
) -> types.ElicitResult | types.ErrorData:
193+
"""Accept elicitation and apply defaults from the schema (SEP-1034)."""
194+
content: dict[str, str | int | float | bool | list[str] | None] = {}
195+
196+
# For form mode, extract defaults from the requested_schema
197+
if isinstance(params, types.ElicitRequestFormParams):
198+
schema = params.requested_schema
199+
logger.debug(f"Elicitation schema: {schema}")
200+
properties = schema.get("properties", {})
201+
for prop_name, prop_schema in properties.items():
202+
if "default" in prop_schema:
203+
content[prop_name] = prop_schema["default"]
204+
logger.debug(f"Applied defaults: {content}")
205+
206+
return types.ElicitResult(action="accept", content=content)
207+
208+
209+
@register("elicitation-sep1034-client-defaults")
210+
async def run_elicitation_defaults(server_url: str) -> None:
211+
"""Connect with elicitation callback that applies schema defaults."""
212+
async with streamable_http_client(url=server_url) as (read_stream, write_stream, _):
213+
async with ClientSession(
214+
read_stream, write_stream, elicitation_callback=default_elicitation_callback
215+
) as session:
216+
await session.initialize()
217+
await session.list_tools()
218+
result = await session.call_tool("test_client_elicitation_defaults", {})
219+
logger.debug(f"test_client_elicitation_defaults result: {result}")
220+
221+
222+
@register("auth/client-credentials-jwt")
223+
async def run_client_credentials_jwt(server_url: str) -> None:
224+
"""Client credentials flow with private_key_jwt authentication."""
225+
context = get_conformance_context()
226+
client_id = context.get("client_id")
227+
private_key_pem = context.get("private_key_pem")
228+
signing_algorithm = context.get("signing_algorithm", "ES256")
229+
230+
if not client_id:
231+
raise RuntimeError("MCP_CONFORMANCE_CONTEXT missing 'client_id'")
232+
if not private_key_pem:
233+
raise RuntimeError("MCP_CONFORMANCE_CONTEXT missing 'private_key_pem'")
234+
235+
jwt_params = SignedJWTParameters(
236+
issuer=client_id,
237+
subject=client_id,
238+
signing_algorithm=signing_algorithm,
239+
signing_key=private_key_pem,
240+
)
241+
242+
oauth_auth = PrivateKeyJWTOAuthProvider(
243+
server_url=server_url,
244+
storage=InMemoryTokenStorage(),
245+
client_id=client_id,
246+
assertion_provider=jwt_params.create_assertion_provider(),
247+
)
248+
249+
await _run_auth_session(server_url, oauth_auth)
250+
251+
252+
@register("auth/client-credentials-basic")
253+
async def run_client_credentials_basic(server_url: str) -> None:
254+
"""Client credentials flow with client_secret_basic authentication."""
255+
context = get_conformance_context()
256+
client_id = context.get("client_id")
257+
client_secret = context.get("client_secret")
258+
259+
if not client_id:
260+
raise RuntimeError("MCP_CONFORMANCE_CONTEXT missing 'client_id'")
261+
if not client_secret:
262+
raise RuntimeError("MCP_CONFORMANCE_CONTEXT missing 'client_secret'")
263+
264+
oauth_auth = ClientCredentialsOAuthProvider(
265+
server_url=server_url,
266+
storage=InMemoryTokenStorage(),
267+
client_id=client_id,
268+
client_secret=client_secret,
269+
token_endpoint_auth_method="client_secret_basic",
270+
)
271+
272+
await _run_auth_session(server_url, oauth_auth)
273+
274+
275+
async def run_auth_code_client(server_url: str) -> None:
276+
"""Authorization code flow (default for auth/* scenarios)."""
277+
callback_handler = ConformanceOAuthCallbackHandler()
278+
279+
oauth_auth = OAuthClientProvider(
280+
server_url=server_url,
281+
client_metadata=OAuthClientMetadata(
282+
client_name="conformance-client",
283+
redirect_uris=[AnyUrl("http://localhost:3000/callback")],
284+
grant_types=["authorization_code", "refresh_token"],
285+
response_types=["code"],
286+
),
287+
storage=InMemoryTokenStorage(),
288+
redirect_handler=callback_handler.handle_redirect,
289+
callback_handler=callback_handler.handle_callback,
290+
client_metadata_url="https://conformance-test.local/client-metadata.json",
291+
)
292+
293+
await _run_auth_session(server_url, oauth_auth)
294+
295+
296+
async def _run_auth_session(server_url: str, oauth_auth: OAuthClientProvider) -> None:
297+
"""Common session logic for all OAuth flows."""
298+
client = httpx.AsyncClient(auth=oauth_auth, timeout=30.0)
299+
async with streamable_http_client(url=server_url, http_client=client) as (read_stream, write_stream, _):
300+
async with ClientSession(
301+
read_stream, write_stream, elicitation_callback=default_elicitation_callback
302+
) as session:
303+
await session.initialize()
304+
logger.debug("Initialized successfully")
305+
306+
tools_result = await session.list_tools()
307+
logger.debug(f"Listed tools: {[t.name for t in tools_result.tools]}")
308+
309+
# Call the first available tool (different tests have different tools)
310+
if tools_result.tools:
311+
tool_name = tools_result.tools[0].name
312+
try:
313+
result = await session.call_tool(tool_name, {})
314+
logger.debug(f"Called {tool_name}, result: {result}")
315+
except Exception as e:
316+
logger.debug(f"Tool call result/error: {e}")
317+
318+
logger.debug("Connection closed successfully")
319+
320+
321+
def main() -> None:
322+
"""Main entry point for the conformance client."""
323+
if len(sys.argv) < 2:
324+
print(f"Usage: {sys.argv[0]} <server-url>", file=sys.stderr)
325+
sys.exit(1)
326+
327+
server_url = sys.argv[1]
328+
scenario = os.environ.get("MCP_CONFORMANCE_SCENARIO")
329+
330+
if scenario:
331+
logger.debug(f"Running explicit scenario '{scenario}' against {server_url}")
332+
handler = HANDLERS.get(scenario)
333+
if handler:
334+
asyncio.run(handler(server_url))
335+
elif scenario.startswith("auth/"):
336+
asyncio.run(run_auth_code_client(server_url))
337+
else:
338+
print(f"Unknown scenario: {scenario}", file=sys.stderr)
339+
sys.exit(1)
340+
else:
341+
logger.debug(f"Running default auth flow against {server_url}")
342+
asyncio.run(run_auth_code_client(server_url))
343+
344+
345+
if __name__ == "__main__":
346+
main()
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
#!/bin/bash
2+
set -e
3+
4+
PORT="${PORT:-3001}"
5+
SERVER_URL="http://localhost:${PORT}/mcp"
6+
7+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
8+
cd "$SCRIPT_DIR/../../.."
9+
10+
# Start everything-server
11+
uv run --frozen mcp-everything-server --port "$PORT" &
12+
SERVER_PID=$!
13+
trap "kill $SERVER_PID 2>/dev/null || true; wait $SERVER_PID 2>/dev/null || true" EXIT
14+
15+
# Wait for server to be ready
16+
MAX_RETRIES=30
17+
RETRY_COUNT=0
18+
while ! curl -s "$SERVER_URL" > /dev/null 2>&1; do
19+
RETRY_COUNT=$((RETRY_COUNT + 1))
20+
if [ $RETRY_COUNT -ge $MAX_RETRIES ]; then
21+
echo "Server failed to start after ${MAX_RETRIES} retries" >&2
22+
exit 1
23+
fi
24+
sleep 0.5
25+
done
26+
27+
echo "Server ready at $SERVER_URL"
28+
29+
# Run conformance tests
30+
npx @modelcontextprotocol/[email protected] server --url "$SERVER_URL" "$@"

0 commit comments

Comments
 (0)