|
11 | 11 | --spec-version is omitted the harness picks per-scenario (LATEST_SPEC_VERSION |
12 | 12 | for active scenarios, DRAFT_PROTOCOL_VERSION for draft-only ones). |
13 | 13 | - Server URL as last CLI argument (sys.argv[1]) |
14 | | - - Must exit 0 within 30 seconds |
| 14 | + - Must exit 0 within the harness --timeout (CI passes 60s; the default is 30s) |
15 | 15 |
|
16 | 16 | Scenarios: |
17 | 17 | initialize - Connect, initialize, list tools, close |
18 | 18 | tools_call - Connect, call add_numbers(a=5, b=3), close |
19 | 19 | sse-retry - Connect, call test_reconnection, close |
20 | 20 | json-schema-ref-no-deref - Connect, list tools (no $ref deref) |
| 21 | + json-schema-2020-12-preservation - List tools, echo the focal inputSchema back verbatim |
21 | 22 | request-metadata - Connect with all callbacks; client stamps _meta |
22 | | - http-standard-headers - Connect, call a tool (Mcp-* headers checked) |
| 23 | + http-standard-headers - Tool, resource and prompt round-trips (Mcp-* headers checked) |
23 | 24 | http-invalid-tool-headers - List tools, call every surfaced tool (x-mcp-header filter) |
| 25 | + http-custom-headers - Replay the harness's toolCalls (x-mcp-header -> Mcp-Param-*) |
24 | 26 | elicitation-sep1034-client-defaults - Elicitation with default accept callback |
25 | 27 | sep-2322-client-request-state - Drive the MRTR auto-loop (SEP-2322) |
26 | 28 | auth/client-credentials-jwt - Client credentials with private_key_jwt |
|
38 | 40 | from typing import Any, cast |
39 | 41 | from urllib.parse import parse_qs, urlparse |
40 | 42 |
|
41 | | -import httpx |
| 43 | +import httpx2 |
42 | 44 | import mcp_types as types |
43 | 45 | from mcp_types.version import MODERN_PROTOCOL_VERSIONS |
44 | 46 | from pydantic import AnyUrl |
@@ -151,7 +153,7 @@ async def handle_redirect(self, authorization_url: str) -> None: |
151 | 153 | """Fetch the authorization URL and extract the auth code from the redirect.""" |
152 | 154 | logger.debug(f"Fetching authorization URL: {authorization_url}") |
153 | 155 |
|
154 | | - async with httpx.AsyncClient() as client: |
| 156 | + async with httpx2.AsyncClient() as client: |
155 | 157 | response = await client.get( |
156 | 158 | authorization_url, |
157 | 159 | follow_redirects=False, |
@@ -252,6 +254,21 @@ async def run_json_schema_ref_no_deref(server_url: str) -> None: |
252 | 254 | await client.list_tools() |
253 | 255 |
|
254 | 256 |
|
| 257 | +@register("json-schema-2020-12-preservation") |
| 258 | +async def run_json_schema_2020_12_preservation(server_url: str) -> None: |
| 259 | + """List tools, then echo the focal tool's inputSchema back verbatim (SEP-1613 / SEP-2106). |
| 260 | +
|
| 261 | + The harness diffs what the client round-trips through `json_schema_echo` against its |
| 262 | + fixture to detect 2020-12 keywords ($schema, $defs, $anchor, additionalProperties, |
| 263 | + allOf/anyOf, if/then/else) being stripped while parsing tools/list. Unlike |
| 264 | + json-schema-ref-no-deref, this mock is version-aware, so client_mode() applies. |
| 265 | + """ |
| 266 | + async with Client(server_url, mode=client_mode()) as client: |
| 267 | + listed = await client.list_tools() |
| 268 | + focal = next(tool for tool in listed.tools if tool.name == "json_schema_2020_12_tool") |
| 269 | + await client.call_tool("json_schema_echo", {"schema": focal.input_schema}) |
| 270 | + |
| 271 | + |
255 | 272 | @register("tools_call") |
256 | 273 | async def run_tools_call(server_url: str) -> None: |
257 | 274 | """Connect, list tools, call add_numbers(a=5, b=3), close.""" |
@@ -294,11 +311,24 @@ async def run_request_metadata(server_url: str) -> None: |
294 | 311 |
|
295 | 312 | @register("http-standard-headers") |
296 | 313 | async def run_http_standard_headers(server_url: str) -> None: |
297 | | - """Connect on the modern path so Mcp-Method / Mcp-Name / MCP-Protocol-Version are sent (SEP-2243).""" |
| 314 | + """Touch tools, resources and prompts on the modern path so each standard header is checked (SEP-2243). |
| 315 | +
|
| 316 | + The scenario inspects Mcp-Method on each request and Mcp-Name on tools/call, |
| 317 | + resources/read and prompts/get, and reports methods the client never sent as |
| 318 | + SKIPPED rather than failed, so exercise one of each. initialize and |
| 319 | + notifications/initialized stay SKIPPED: the modern path discovers via |
| 320 | + server/discover and never sends them. |
| 321 | + """ |
298 | 322 | async with Client(server_url, mode=client_mode()) as client: |
299 | | - await client.list_tools() |
300 | | - result = await client.call_tool("add_numbers", {"a": 5, "b": 3}) |
301 | | - logger.debug(f"add_numbers result: {result}") |
| 323 | + tools = await client.list_tools() |
| 324 | + if tools.tools: |
| 325 | + await client.call_tool(tools.tools[0].name, _stub_required_args(tools.tools[0].input_schema)) |
| 326 | + resources = await client.list_resources() |
| 327 | + if resources.resources: |
| 328 | + await client.read_resource(resources.resources[0].uri) |
| 329 | + prompts = await client.list_prompts() |
| 330 | + if prompts.prompts: |
| 331 | + await client.get_prompt(prompts.prompts[0].name) |
302 | 332 |
|
303 | 333 |
|
304 | 334 | def _stub_required_args(input_schema: dict[str, Any]) -> dict[str, Any]: |
@@ -486,13 +516,13 @@ async def run_enterprise_managed_authorization(server_url: str) -> None: |
486 | 516 | # learn it from the harness's PRM document (RFC 9728); production |
487 | 517 | # deployments would supply it as static configuration instead. |
488 | 518 | prm_url = build_protected_resource_metadata_discovery_urls(None, server_url)[0] |
489 | | - async with httpx.AsyncClient(timeout=30.0) as http: |
| 519 | + async with httpx2.AsyncClient(timeout=30.0) as http: |
490 | 520 | prm = (await http.get(prm_url)).raise_for_status().json() |
491 | 521 | as_issuer = prm["authorization_servers"][0] |
492 | 522 |
|
493 | 523 | async def fetch_id_jag(audience: str, resource: str) -> str: |
494 | 524 | """Leg 1 - RFC 8693 token-exchange at the enterprise IdP.""" |
495 | | - async with httpx.AsyncClient(timeout=30.0) as http: |
| 525 | + async with httpx2.AsyncClient(timeout=30.0) as http: |
496 | 526 | resp = await http.post( |
497 | 527 | idp_token_endpoint, |
498 | 528 | data={ |
@@ -563,9 +593,9 @@ async def run_auth_code_client(server_url: str) -> None: |
563 | 593 | await _run_auth_session(server_url, oauth_auth) |
564 | 594 |
|
565 | 595 |
|
566 | | -async def _run_auth_session(server_url: str, oauth_auth: httpx.Auth) -> None: |
| 596 | +async def _run_auth_session(server_url: str, oauth_auth: httpx2.Auth) -> None: |
567 | 597 | """Common session logic for all OAuth flows.""" |
568 | | - http_client = httpx.AsyncClient(auth=oauth_auth, timeout=30.0) |
| 598 | + http_client = httpx2.AsyncClient(auth=oauth_auth, timeout=30.0) |
569 | 599 | transport = streamable_http_client(url=server_url, http_client=http_client) |
570 | 600 | async with Client(transport, mode=client_mode(), elicitation_callback=default_elicitation_callback) as client: |
571 | 601 | logger.debug("Initialized successfully") |
|
0 commit comments