|
2 | 2 |
|
3 | 3 | A pinned session stamps the ``io.modelcontextprotocol/*`` `_meta` envelope onto every outgoing |
4 | 4 | request, and the streamable-HTTP transport derives the ``MCP-Protocol-Version`` / ``Mcp-Method`` / |
5 | | -``Mcp-Name`` headers from that body. These tests pin the transport-level derivation as pure unit |
6 | | -assertions on the private helpers -- the headers are an HTTP-seam observation that the public |
7 | | -client never exposes, and no in-process 2026 server exists yet to record them against. |
| 5 | +``Mcp-Name`` headers from that body. These tests pin the composition through a real ``httpx`` |
| 6 | +request against a canned ``httpx.MockTransport`` -- no in-process 2026 server exists yet to record |
| 7 | +the headers against. The header-derivation helpers themselves are unit-tested in |
| 8 | +``tests/client/test_streamable_http.py``. |
8 | 9 | """ |
9 | 10 |
|
10 | | -import base64 |
| 11 | +import json |
11 | 12 |
|
| 13 | +import anyio |
| 14 | +import httpx |
12 | 15 | import pytest |
13 | 16 | from inline_snapshot import snapshot |
14 | 17 |
|
15 | | -from mcp.client.streamable_http import _body_derived_headers, _encode_header_value |
16 | | -from mcp.types import PROTOCOL_VERSION_META_KEY, JSONRPCMessage, JSONRPCNotification, JSONRPCRequest |
| 18 | +from mcp.client import ClientSession |
| 19 | +from mcp.client.streamable_http import streamable_http_client |
| 20 | +from mcp.types import Implementation |
| 21 | +from tests.interaction._connect import BASE_URL |
17 | 22 | from tests.interaction._requirements import requirement |
18 | 23 |
|
19 | 24 | pytestmark = pytest.mark.anyio |
20 | 25 |
|
21 | 26 |
|
22 | | -_ENVELOPE = {PROTOCOL_VERSION_META_KEY: "2026-07-28"} |
23 | | - |
24 | | - |
25 | 27 | @requirement("client-transport:http:body-derived-headers") |
26 | | -@pytest.mark.parametrize( |
27 | | - ("message", "expected"), |
28 | | - [ |
29 | | - ( |
30 | | - JSONRPCRequest( |
31 | | - jsonrpc="2.0", id=1, method="tools/call", params={"name": "add", "arguments": {}, "_meta": _ENVELOPE} |
32 | | - ), |
33 | | - snapshot({"mcp-protocol-version": "2026-07-28", "mcp-method": "tools/call", "mcp-name": "add"}), |
34 | | - ), |
35 | | - ( |
36 | | - JSONRPCRequest(jsonrpc="2.0", id=2, method="tools/list", params={"_meta": _ENVELOPE}), |
37 | | - snapshot({"mcp-protocol-version": "2026-07-28", "mcp-method": "tools/list"}), |
38 | | - ), |
39 | | - ( |
40 | | - JSONRPCRequest(jsonrpc="2.0", id=3, method="tools/call", params={"name": "add", "arguments": {}}), |
41 | | - snapshot({}), |
42 | | - ), |
43 | | - ( |
44 | | - JSONRPCNotification(jsonrpc="2.0", method="notifications/initialized"), |
45 | | - snapshot({}), |
46 | | - ), |
47 | | - ], |
48 | | -) |
49 | | -def test_body_derived_headers_reflect_the_envelope_on_the_request_body( |
50 | | - message: JSONRPCMessage, expected: dict[str, str] |
51 | | -) -> None: |
52 | | - """An envelope-bearing body yields the three stateless headers; a legacy body yields none. |
53 | | -
|
54 | | - Spec-mandated for the headers themselves; tested as a unit on the private helper because the |
55 | | - headers are an HTTP-seam observation -- the public client never exposes outbound request headers, |
56 | | - and no in-process 2026 server exists to record them against. Legacy bodies returning ``{}`` is |
57 | | - what keeps the unpinned wire byte-identical (see ``test_legacy_wire.py``). |
| 28 | +@requirement("lifecycle:stateless:request-envelope") |
| 29 | +async def test_pinned_session_post_carries_body_derived_headers_on_the_wire() -> None: |
| 30 | + """A pinned ``call_tool`` over streamable HTTP lands as a POST whose headers were derived from its body. |
| 31 | +
|
| 32 | + Spec-mandated for the body-derived headers and the request envelope: this is the wire-seam proof |
| 33 | + that the ``ClientSession`` envelope stamp and the transport's header derivation are actually |
| 34 | + composed -- the streamable-HTTP POST wiring is driven through a real ``httpx`` request. A canned |
| 35 | + ``httpx.MockTransport`` stands in for the (not-yet-existing) 2026 server; the ``isError`` result |
| 36 | + skips the client's implicit ``tools/list`` output-schema fetch so the recorded log is the single |
| 37 | + POST. |
58 | 38 | """ |
59 | | - assert _body_derived_headers(message) == expected |
60 | | - |
61 | | - |
62 | | -@requirement("client-transport:http:mcp-name-encoding") |
63 | | -@pytest.mark.parametrize( |
64 | | - ("raw", "expected", "wrapped"), |
65 | | - [ |
66 | | - ("add", snapshot("add"), False), |
67 | | - ("tool with spaces", snapshot("tool with spaces"), False), |
68 | | - ("résumé", snapshot("=?base64?csOpc3Vtw6k=?="), True), |
69 | | - ("a\r\nb", snapshot("=?base64?YQ0KYg==?="), True), |
70 | | - ("=?base64?Zm9v?=", snapshot("=?base64?PT9iYXNlNjQ/Wm05dj89?="), True), |
71 | | - ], |
72 | | -) |
73 | | -def test_mcp_name_header_values_are_base64_wrapped_when_unsafe_for_an_http_field( |
74 | | - raw: str, expected: str, wrapped: bool |
75 | | -) -> None: |
76 | | - """Printable-ASCII names pass verbatim; CR/LF, non-ASCII, and sentinel-shaped names are wrapped. |
77 | | -
|
78 | | - Spec-mandated: the ``=?base64?...?=`` sentinel is the spec's RFC 7230 safety gate for the |
79 | | - ``Mcp-Name`` header. Unit test of the private helper for the same reason as |
80 | | - :func:`_body_derived_headers` -- the encoded value is only observable on the raw HTTP request. |
81 | | - Wrapped values round-trip through base64 so the server can recover the original name. |
| 39 | + recorded: list[httpx.Request] = [] |
| 40 | + |
| 41 | + def handler(request: httpx.Request) -> httpx.Response: |
| 42 | + recorded.append(request) |
| 43 | + body = json.loads(request.content) |
| 44 | + result = {"content": [{"type": "text", "text": "5"}], "isError": True, "resultType": "complete"} |
| 45 | + return httpx.Response(200, json={"jsonrpc": "2.0", "id": body["id"], "result": result}) |
| 46 | + |
| 47 | + with anyio.fail_after(5): |
| 48 | + async with ( |
| 49 | + httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http, |
| 50 | + streamable_http_client(f"{BASE_URL}/mcp", http_client=http) as (read, write), |
| 51 | + ClientSession( |
| 52 | + read, |
| 53 | + write, |
| 54 | + client_info=Implementation(name="pin-client", version="1.0.0"), |
| 55 | + protocol_version="2026-07-28", |
| 56 | + ) as session, |
| 57 | + ): |
| 58 | + await session.call_tool("add", {"a": 2, "b": 3}) |
| 59 | + |
| 60 | + assert [r.method for r in recorded] == snapshot(["POST"]) |
| 61 | + post = recorded[0] |
| 62 | + assert {k: v for k, v in post.headers.items() if k.startswith("mcp-")} == snapshot( |
| 63 | + {"mcp-protocol-version": "2026-07-28", "mcp-method": "tools/call", "mcp-name": "add"} |
| 64 | + ) |
| 65 | + assert json.loads(post.content)["params"]["_meta"] == snapshot( |
| 66 | + { |
| 67 | + "io.modelcontextprotocol/protocolVersion": "2026-07-28", |
| 68 | + "io.modelcontextprotocol/clientInfo": {"name": "pin-client", "version": "1.0.0"}, |
| 69 | + "io.modelcontextprotocol/clientCapabilities": {}, |
| 70 | + } |
| 71 | + ) |
| 72 | + |
| 73 | + |
| 74 | +@requirement("client-transport:http:stateless-ignores-session-id") |
| 75 | +async def test_pinned_session_ignores_returned_session_id_and_never_opens_get_or_delete() -> None: |
| 76 | + """A server-issued ``Mcp-Session-Id`` never reaches a pinned client's wire: only POSTs are sent. |
| 77 | +
|
| 78 | + Spec-mandated for the stateless transport: the session-id capture, the standalone GET listening |
| 79 | + stream, and the DELETE-on-close are all gated on state a pinned session never produces (no |
| 80 | + ``initialize``, no ``notifications/initialized``), so even when the canned server volunteers a |
| 81 | + session id on every response the recorded log stays POST-only and no request echoes the id back. |
| 82 | + The successful ``tools/call`` triggers the client's implicit ``tools/list`` output-schema fetch so |
| 83 | + there is a second POST after the id was offered. |
82 | 84 | """ |
83 | | - encoded = _encode_header_value(raw) |
84 | | - assert encoded == expected |
85 | | - if wrapped: |
86 | | - assert encoded.startswith("=?base64?") and encoded.endswith("?=") |
87 | | - assert base64.b64decode(encoded.removeprefix("=?base64?").removesuffix("?=")).decode() == raw |
88 | | - else: |
89 | | - assert encoded == raw |
| 85 | + recorded: list[httpx.Request] = [] |
| 86 | + |
| 87 | + def handler(request: httpx.Request) -> httpx.Response: |
| 88 | + recorded.append(request) |
| 89 | + body = json.loads(request.content) |
| 90 | + if body["method"] == "tools/list": |
| 91 | + result: dict[str, object] = { |
| 92 | + "tools": [{"name": "add", "inputSchema": {"type": "object"}}], |
| 93 | + "resultType": "complete", |
| 94 | + "ttlMs": 0, |
| 95 | + "cacheScope": "public", |
| 96 | + } |
| 97 | + else: |
| 98 | + result = {"content": [{"type": "text", "text": "5"}], "isError": False, "resultType": "complete"} |
| 99 | + return httpx.Response( |
| 100 | + 200, json={"jsonrpc": "2.0", "id": body["id"], "result": result}, headers={"mcp-session-id": "srv-123"} |
| 101 | + ) |
| 102 | + |
| 103 | + with anyio.fail_after(5): |
| 104 | + async with ( |
| 105 | + httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http, |
| 106 | + streamable_http_client(f"{BASE_URL}/mcp", http_client=http) as (read, write), |
| 107 | + ClientSession(read, write, protocol_version="2026-07-28") as session, |
| 108 | + ): |
| 109 | + await session.call_tool("add", {"a": 2, "b": 3}) |
| 110 | + |
| 111 | + assert [r.method for r in recorded] == snapshot(["POST", "POST"]) |
| 112 | + assert all("mcp-session-id" not in r.headers for r in recorded) |
0 commit comments