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.
1+ """MCP conformance test client for the @modelcontextprotocol/conformance harness.
52
63Contract:
7- - MCP_CONFORMANCE_SCENARIO env var -> scenario name
8- - MCP_CONFORMANCE_CONTEXT env var -> optional JSON (for client-credentials scenarios)
9- - MCP_CONFORMANCE_PROTOCOL_VERSION env var -> spec version the harness mock
10- server is speaking (e.g. "2025-11-25", "2026-07-28"). Always set; when
11- --spec-version is omitted the harness picks per-scenario (LATEST_SPEC_VERSION
12- for active scenarios, DRAFT_PROTOCOL_VERSION for draft-only ones).
4+ - MCP_CONFORMANCE_SCENARIO env var -> scenario name (see HANDLERS; auth/* falls back to the auth code flow)
5+ - MCP_CONFORMANCE_CONTEXT env var -> optional JSON (client-credentials scenarios)
6+ - MCP_CONFORMANCE_PROTOCOL_VERSION env var -> spec version the harness mock server speaks
137 - Server URL as last CLI argument (sys.argv[1])
148 - Must exit 0 within 30 seconds
15-
16- Scenarios:
17- initialize - Connect, initialize, list tools, close
18- tools_call - Connect, call add_numbers(a=5, b=3), close
19- sse-retry - Connect, call test_reconnection, close
20- json-schema-ref-no-deref - Connect, list tools (no $ref deref)
21- request-metadata - Connect with all callbacks; client stamps _meta
22- http-standard-headers - Connect, call a tool (Mcp-* headers checked)
23- http-invalid-tool-headers - List tools, call every surfaced tool (x-mcp-header filter)
24- elicitation-sep1034-client-defaults - Elicitation with default accept callback
25- sep-2322-client-request-state - Drive the MRTR auto-loop (SEP-2322)
26- auth/client-credentials-jwt - Client credentials with private_key_jwt
27- auth/client-credentials-basic - Client credentials with client_secret_basic
28- auth/enterprise-managed-authorization - SEP-990 ID-JAG (RFC 8693 + RFC 7523 jwt-bearer)
29- auth/* - Authorization code flow (default for auth scenarios)
309"""
3110
3211import asyncio
6443)
6544logger = logging .getLogger (__name__ )
6645
67- #: Spec version the harness is running this scenario at (e.g. "2025-11-25",
68- #: "2026-07-28"). The harness always sets this (when --spec-version is omitted
69- #: it picks per-scenario: LATEST_SPEC_VERSION for active scenarios,
70- #: DRAFT_PROTOCOL_VERSION for draft-only ones), so None means we were invoked
71- #: outside the harness.
46+ #: Spec version the harness mock server speaks (e.g. "2025-11-25"). The harness
47+ #: always sets this, so None means we were invoked outside it.
7248PROTOCOL_VERSION : str | None = os .environ .get ("MCP_CONFORMANCE_PROTOCOL_VERSION" )
7349
7450
7551def client_mode () -> str :
7652 """Pick the Client(mode=) for the harness leg.
7753
78- On a modern leg (2026-07-28+) -> 'auto' so Client.discover() runs and the
79- _meta envelope + MCP-Protocol-Version header are stamped on every request.
80- On a handshake-era leg -> 'legacy' so the initialize handshake runs exactly
81- as before (no server/discover probe is sent against a mock that would 400 it).
82- Outside the harness -> 'auto' (probe + fallback).
54+ 'auto' on modern legs (2026-07-28+) and outside the harness; 'legacy' on handshake-era
55+ legs so no server/discover probe is sent against a mock that would 400 it.
8356 """
8457 if PROTOCOL_VERSION is None or PROTOCOL_VERSION in MODERN_PROTOCOL_VERSIONS :
8558 return "auto"
8659 return "legacy"
8760
8861
89- # Type for async scenario handler functions
9062ScenarioHandler = Callable [[str ], Coroutine [Any , None , None ]]
9163
92- # Registry of scenario handlers
9364HANDLERS : dict [str , ScenarioHandler ] = {}
9465
9566
@@ -138,17 +109,14 @@ async def set_client_info(self, client_info: OAuthClientInformationFull) -> None
138109
139110
140111class ConformanceOAuthCallbackHandler :
141- """OAuth callback handler that automatically fetches the authorization URL
142- and extracts the auth code, without requiring user interaction.
143- """
112+ """Fetches the authorization URL and extracts the auth code without user interaction."""
144113
145114 def __init__ (self ) -> None :
146115 self ._auth_code : str | None = None
147116 self ._state : str | None = None
148117 self ._iss : str | None = None
149118
150119 async def handle_redirect (self , authorization_url : str ) -> None :
151- """Fetch the authorization URL and extract the auth code from the redirect."""
152120 logger .debug (f"Fetching authorization URL: { authorization_url } " )
153121
154122 async with httpx .AsyncClient () as client :
@@ -179,7 +147,6 @@ async def handle_redirect(self, authorization_url: str) -> None:
179147 raise RuntimeError (f"Expected redirect response, got { response .status_code } from { authorization_url } " )
180148
181149 async def handle_callback (self ) -> AuthorizationCodeResult :
182- """Return the captured auth code, state, and iss."""
183150 if self ._auth_code is None :
184151 raise RuntimeError ("No authorization code available - was handle_redirect called?" )
185152 result = AuthorizationCodeResult (code = self ._auth_code , state = self ._state , iss = self ._iss )
@@ -189,9 +156,6 @@ async def handle_callback(self) -> AuthorizationCodeResult:
189156 return result
190157
191158
192- # --- Stub callbacks (declare capabilities in _meta without doing real work) ---
193-
194-
195159async def stub_sampling_callback (
196160 context : ClientRequestContext ,
197161 params : types .CreateMessageRequestParams ,
@@ -214,7 +178,6 @@ async def default_elicitation_callback(
214178 """Accept elicitation and apply defaults from the schema (SEP-1034)."""
215179 content : dict [str , str | int | float | bool | list [str ] | None ] = {}
216180
217- # For form mode, extract defaults from the requested_schema
218181 if isinstance (params , types .ElicitRequestFormParams ):
219182 schema = params .requested_schema
220183 logger .debug (f"Elicitation schema: { schema } " )
@@ -227,12 +190,8 @@ async def default_elicitation_callback(
227190 return types .ElicitResult (action = "accept" , content = content )
228191
229192
230- # --- Scenario Handlers ---
231-
232-
233193@register ("initialize" )
234194async def run_initialize (server_url : str ) -> None :
235- """Connect, initialize, list tools, close."""
236195 async with Client (server_url , mode = client_mode ()) as client :
237196 logger .debug ("Initialized successfully" )
238197 await client .list_tools ()
@@ -241,20 +200,17 @@ async def run_initialize(server_url: str) -> None:
241200
242201@register ("json-schema-ref-no-deref" )
243202async def run_json_schema_ref_no_deref (server_url : str ) -> None :
244- """Initialize and list tools; the scenario fails only if the client fetches a network $ref.
203+ """List tools; the scenario fails only if the client fetches a network $ref (SEP-2106) .
245204
246- The client never walks inputSchema or resolves $refs, so listing is enough (SEP-2106).
247- Pinned to mode='legacy': the harness reports PROTOCOL_VERSION=2026-07-28 for this
248- scenario but its mock server only speaks the handshake-era lifecycle and 400s a
249- modern-stamped tools/list. The check is lifecycle-agnostic so this is harmless.
205+ Pinned to mode='legacy': the harness reports PROTOCOL_VERSION=2026-07-28 here, but its
206+ mock only speaks the handshake-era lifecycle and 400s a modern-stamped tools/list.
250207 """
251208 async with Client (server_url , mode = "legacy" ) as client :
252209 await client .list_tools ()
253210
254211
255212@register ("tools_call" )
256213async def run_tools_call (server_url : str ) -> None :
257- """Connect, list tools, call add_numbers(a=5, b=3), close."""
258214 async with Client (server_url , mode = client_mode ()) as client :
259215 await client .list_tools ()
260216 result = await client .call_tool ("add_numbers" , {"a" : 5 , "b" : 3 })
@@ -263,7 +219,6 @@ async def run_tools_call(server_url: str) -> None:
263219
264220@register ("sse-retry" )
265221async def run_sse_retry (server_url : str ) -> None :
266- """Connect, list tools, call test_reconnection, close."""
267222 async with Client (server_url , mode = client_mode ()) as client :
268223 await client .list_tools ()
269224 result = await client .call_tool ("test_reconnection" , {})
@@ -274,11 +229,9 @@ async def run_sse_retry(server_url: str) -> None:
274229async def run_request_metadata (server_url : str ) -> None :
275230 """Connect on the modern path with every client capability declared.
276231
277- The scenario inspects every request's `_meta` envelope (SEP-2575) for
278- protocolVersion / clientInfo / clientCapabilities, and the matching
279- MCP-Protocol-Version header. mode='auto' makes the SDK send
280- server/discover (covering the unsupported-version retry check), then adopt
281- and stamp the envelope on the follow-up requests.
232+ The scenario inspects each request's `_meta` envelope (SEP-2575) and MCP-Protocol-Version
233+ header; mode='auto' sends server/discover (covering the unsupported-version retry check),
234+ then stamps the envelope on follow-up requests.
282235 """
283236 async with Client (
284237 server_url ,
@@ -320,13 +273,9 @@ def _stub_required_args(input_schema: dict[str, Any]) -> dict[str, Any]:
320273async def run_http_invalid_tool_headers (server_url : str ) -> None :
321274 """List tools, then call every tool the SDK surfaces (SEP-2243).
322275
323- The harness mock advertises one valid tool plus several with malformed
324- x-mcp-header annotations (empty, non-primitive type, duplicate, invalid
325- chars). The scenario passes if valid_tool is called and the malformed
326- ones are not -- so a conforming client filters them out of the list_tools
327- result and the loop below never sees them. The scenario sets
328- allowClientError, so a per-call failure is logged and skipped rather
329- than aborting the whole run.
276+ The mock advertises one valid tool plus several with malformed x-mcp-header annotations;
277+ a conforming client filters those out of the list_tools result so the loop never sees
278+ them. The scenario sets allowClientError, so per-call failures are logged, not fatal.
330279 """
331280 async with Client (server_url , mode = client_mode ()) as client :
332281 listed = await client .list_tools ()
@@ -340,13 +289,11 @@ async def run_http_invalid_tool_headers(server_url: str) -> None:
340289
341290@register ("http-custom-headers" )
342291async def run_http_custom_headers (server_url : str ) -> None :
343- """List tools, then replay the harness's `toolCalls` so x-mcp-header args mirror into headers (SEP-2243).
292+ """Replay the harness's `toolCalls` verbatim so x-mcp-header args mirror into headers (SEP-2243).
344293
345- The scenario supplies the exact arguments to send (including the null/edge-case values that
346- exercise omission and Base64 encoding) via the context `toolCalls`; using them verbatim is
347- what drives every per-parameter check. `list_tools` first so the SDK caches each tool's
348- annotations; a tool the SDK dropped (invalid annotations) is skipped. Per-call failures are
349- logged and skipped rather than aborting the run.
294+ The context supplies the exact arguments (including null/edge-case values exercising omission
295+ and Base64 encoding). `list_tools` first so the SDK caches each tool's annotations; tools the
296+ SDK dropped (invalid annotations) are skipped, and per-call failures are logged, not fatal.
350297 """
351298 tool_calls : list [dict [str , Any ]] = []
352299 if os .environ .get ("MCP_CONFORMANCE_CONTEXT" ):
@@ -368,7 +315,6 @@ async def run_http_custom_headers(server_url: str) -> None:
368315
369316@register ("elicitation-sep1034-client-defaults" )
370317async def run_elicitation_defaults (server_url : str ) -> None :
371- """Connect with elicitation callback that applies schema defaults."""
372318 async with Client (server_url , mode = client_mode (), elicitation_callback = default_elicitation_callback ) as client :
373319 await client .list_tools ()
374320 result = await client .call_tool ("test_client_elicitation_defaults" , {})
@@ -379,13 +325,10 @@ async def run_elicitation_defaults(server_url: str) -> None:
379325async def run_mrtr_client (server_url : str ) -> None :
380326 """Drive the SEP-2322 client mock through `Client.call_tool`'s auto-loop.
381327
382- The mock inspects raw `tools/call` params, so registering an
383- `elicitation_callback` and letting the driver run is enough to satisfy
384- all five wire-shape checks: the driver echoes `request_state` byte-exact
385- and omits it when the server sent none, every retry mints a fresh
386- JSON-RPC id, the unrelated call between auto-loops carries no MRTR
387- params, and the no-`resultType` response parses as a terminal
388- `CallToolResult` so the driver never retries it.
328+ The mock inspects raw `tools/call` params: the driver must echo `request_state`
329+ byte-exact (omitting it when the server sent none), mint a fresh JSON-RPC id per
330+ retry, keep MRTR params off unrelated calls, and treat a no-`resultType` response
331+ as a terminal `CallToolResult`.
389332 """
390333
391334 async def confirm (
@@ -459,9 +402,7 @@ async def run_client_credentials_basic(server_url: str) -> None:
459402
460403@register ("auth/enterprise-managed-authorization" )
461404async def run_enterprise_managed_authorization (server_url : str ) -> None :
462- """SEP-990 enterprise-managed authorization: RFC 8693 token-exchange at the
463- enterprise IdP for an ID-JAG, then RFC 7523 jwt-bearer at the MCP
464- authorization server."""
405+ """SEP-990: RFC 8693 token-exchange at the IdP for an ID-JAG, then RFC 7523 jwt-bearer at the MCP AS."""
465406 context = get_conformance_context ()
466407 client_id = context .get ("client_id" )
467408 client_secret = context .get ("client_secret" )
@@ -480,11 +421,9 @@ async def run_enterprise_managed_authorization(server_url: str) -> None:
480421 if not idp_token_endpoint :
481422 raise RuntimeError ("MCP_CONFORMANCE_CONTEXT missing 'idp_token_endpoint'" )
482423
483- # IdentityAssertionOAuthProvider takes the AS issuer as configuration (the
484- # SEP-990 trust model: the resource server is never asked which AS to use).
485- # The harness does not put the issuer in context, so for conformance we
486- # learn it from the harness's PRM document (RFC 9728); production
487- # deployments would supply it as static configuration instead.
424+ # SEP-990 trust model: the AS issuer is client configuration, never asked of the resource
425+ # server. The harness omits it from context, so learn it from the PRM document (RFC 9728);
426+ # production deployments would configure it statically.
488427 prm_url = build_protected_resource_metadata_discovery_urls (None , server_url )[0 ]
489428 async with httpx .AsyncClient (timeout = 30.0 ) as http :
490429 prm = (await http .get (prm_url )).raise_for_status ().json ()
@@ -526,7 +465,6 @@ async def run_auth_code_client(server_url: str) -> None:
526465 callback_handler = ConformanceOAuthCallbackHandler ()
527466 storage = InMemoryTokenStorage ()
528467
529- # Check for pre-registered client credentials from context
530468 context_json = os .environ .get ("MCP_CONFORMANCE_CONTEXT" )
531469 if context_json :
532470 try :
@@ -573,7 +511,7 @@ async def _run_auth_session(server_url: str, oauth_auth: httpx.Auth) -> None:
573511 tools_result = await client .list_tools ()
574512 logger .debug (f"Listed tools: { [t .name for t in tools_result .tools ]} " )
575513
576- # Call the first available tool ( different tests have different tools)
514+ # Different tests expose different tools; call the first one
577515 if tools_result .tools :
578516 tool_name = tools_result .tools [0 ].name
579517 try :
@@ -586,7 +524,6 @@ async def _run_auth_session(server_url: str, oauth_auth: httpx.Auth) -> None:
586524
587525
588526def main () -> None :
589- """Main entry point for the conformance client."""
590527 if len (sys .argv ) < 2 :
591528 print (f"Usage: { sys .argv [0 ]} <server-url>" , file = sys .stderr )
592529 sys .exit (1 )
0 commit comments