Skip to content

Commit cced4aa

Browse files
committed
Add MockTransport, capstone, and client-unit tests for the 2026-07-28 path
- transports/test_client_transport_http_modern.py: pinned session POST carries body-derived headers on the wire; a returned session id is ignored and no GET/DELETE is sent - transports/test_hosting_http_modern.py: non-2026 headers fall through to the legacy transport unchanged; handler exceptions map to INTERNAL_ERROR with a generic message; capstone end-to-end stateless tools/call (real ClientSession against the modern entry) - tests/client/test_streamable_http.py: unit tests for _body_derived_headers and the _encode_header_value Base64-sentinel gate (private-helper coverage, kept out of the interaction suite) Claude-Session: https://claude.ai/code/session_017S3aJaxEHeMvftp6whnHWK
1 parent 4709c71 commit cced4aa

3 files changed

Lines changed: 303 additions & 74 deletions

File tree

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
"""Unit tests for the streamable-HTTP client transport.
2+
3+
The full client<->server round trip is pinned by the interaction suite under
4+
tests/interaction/transports/; these tests cover the private header-derivation helpers
5+
directly because the headers are an HTTP-seam observation the public client never exposes.
6+
"""
7+
8+
import base64
9+
10+
import pytest
11+
from inline_snapshot import snapshot
12+
13+
from mcp.client.streamable_http import _body_derived_headers, _encode_header_value
14+
from mcp.types import PROTOCOL_VERSION_META_KEY, JSONRPCMessage, JSONRPCNotification, JSONRPCRequest
15+
16+
_ENVELOPE = {PROTOCOL_VERSION_META_KEY: "2026-07-28"}
17+
18+
19+
@pytest.mark.parametrize(
20+
("message", "expected"),
21+
[
22+
(
23+
JSONRPCRequest(
24+
jsonrpc="2.0", id=1, method="tools/call", params={"name": "add", "arguments": {}, "_meta": _ENVELOPE}
25+
),
26+
snapshot({"mcp-protocol-version": "2026-07-28", "mcp-method": "tools/call", "mcp-name": "add"}),
27+
),
28+
(
29+
JSONRPCRequest(jsonrpc="2.0", id=2, method="tools/list", params={"_meta": _ENVELOPE}),
30+
snapshot({"mcp-protocol-version": "2026-07-28", "mcp-method": "tools/list"}),
31+
),
32+
(
33+
JSONRPCRequest(jsonrpc="2.0", id=3, method="tools/call", params={"name": "add", "arguments": {}}),
34+
snapshot({}),
35+
),
36+
(
37+
JSONRPCNotification(jsonrpc="2.0", method="notifications/initialized"),
38+
snapshot({}),
39+
),
40+
],
41+
)
42+
def test_body_derived_headers_reflect_the_envelope_on_the_request_body(
43+
message: JSONRPCMessage, expected: dict[str, str]
44+
) -> None:
45+
"""An envelope-bearing body yields the three stateless headers; a legacy body yields none.
46+
47+
Legacy bodies returning ``{}`` is what keeps the unpinned wire byte-identical to a pre-2026 client.
48+
"""
49+
assert _body_derived_headers(message) == expected
50+
51+
52+
@pytest.mark.parametrize(
53+
("raw", "expected", "wrapped"),
54+
[
55+
("add", snapshot("add"), False),
56+
("tool with spaces", snapshot("tool with spaces"), False),
57+
("résumé", snapshot("=?base64?csOpc3Vtw6k=?="), True),
58+
("a\r\nb", snapshot("=?base64?YQ0KYg==?="), True),
59+
("=?base64?Zm9v?=", snapshot("=?base64?PT9iYXNlNjQ/Wm05dj89?="), True),
60+
],
61+
)
62+
def test_mcp_name_header_values_are_base64_wrapped_when_unsafe_for_an_http_field(
63+
raw: str, expected: str, wrapped: bool
64+
) -> None:
65+
"""Printable-ASCII names pass verbatim; CR/LF, non-ASCII, and sentinel-shaped names are wrapped.
66+
67+
The ``=?base64?...?=`` sentinel is the spec's RFC 7230 safety gate for the ``Mcp-Name`` header.
68+
Wrapped values round-trip through base64 so the server can recover the original name.
69+
"""
70+
encoded = _encode_header_value(raw)
71+
assert encoded == expected
72+
if wrapped:
73+
assert encoded.startswith("=?base64?") and encoded.endswith("?=")
74+
assert base64.b64decode(encoded.removeprefix("=?base64?").removesuffix("?=")).decode() == raw
75+
else:
76+
assert encoded == raw

tests/interaction/transports/test_client_transport_http_modern.py

Lines changed: 94 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -2,88 +2,111 @@
22
33
A pinned session stamps the ``io.modelcontextprotocol/*`` `_meta` envelope onto every outgoing
44
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``.
89
"""
910

10-
import base64
11+
import json
1112

13+
import anyio
14+
import httpx
1215
import pytest
1316
from inline_snapshot import snapshot
1417

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
1722
from tests.interaction._requirements import requirement
1823

1924
pytestmark = pytest.mark.anyio
2025

2126

22-
_ENVELOPE = {PROTOCOL_VERSION_META_KEY: "2026-07-28"}
23-
24-
2527
@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.
5838
"""
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.
8284
"""
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

Comments
 (0)