Skip to content

Commit 194f225

Browse files
committed
Address review feedback and consolidate 2026-07-28 interaction tests
Review-response changes: - Merge the duplicate _pinned_version guard in ClientSession.send_request - Use is_version_at_least() instead of a raw string compare for the per-message-headers gate - Base64-wrap Mcp-Name values with leading/trailing spaces (RFC 7230 forbids them; h11 rejects on real transports) - Add a TODO at the Mcp-Name gate naming prompts/get and resources/read - Type the protocol_version pin as Literal["2026-07-28"] via StatelessProtocolVersion so 2025-era values are a type error - Reword the _related_request_id TODO in ServerSession to point at the per-request Outbound shape (not at widening the Protocol) Interaction-suite consolidation: - Drop test_lifecycle_stateless.py and test_client_transport_http_modern.py; their assertions are now proven by the capstone in test_hosting_http_modern.py (envelope, headers, no-initialize) or moved to tests/client/ (initialize-raises, session-id-ignore against a misbehaving peer) - Extend the capstone to capture ctx.meta server-side and assert the caller-supplied _meta key survives the envelope merge - Reconcile _requirements.py: stack request-envelope and caller-meta-preserved on the capstone; defer no-initialize and stateless-ignores-session-id to tests/client/; drop the duplicate unpinned-legacy-wire and body-derived-headers entries
1 parent 4378d15 commit 194f225

10 files changed

Lines changed: 122 additions & 391 deletions

File tree

src/mcp/client/session.py

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
from mcp.shared.message import ClientMessageMetadata, SessionMessage
2222
from mcp.shared.session import RequestResponder
2323
from mcp.shared.transport_context import TransportContext
24-
from mcp.shared.version import SUPPORTED_PROTOCOL_VERSIONS
24+
from mcp.shared.version import SUPPORTED_PROTOCOL_VERSIONS, StatelessProtocolVersion
2525
from mcp.types import (
2626
CLIENT_CAPABILITIES_META_KEY,
2727
CLIENT_INFO_META_KEY,
@@ -149,7 +149,7 @@ def __init__(
149149
message_handler: MessageHandlerFnT | None = None,
150150
client_info: types.Implementation | None = None,
151151
*,
152-
protocol_version: str | None = None,
152+
protocol_version: StatelessProtocolVersion | None = None,
153153
sampling_capabilities: types.SamplingCapability | None = None,
154154
dispatcher: Dispatcher[Any] | None = None,
155155
) -> None:
@@ -228,6 +228,7 @@ async def send_request(
228228
"""
229229
data = request.model_dump(by_alias=True, mode="json", exclude_none=True)
230230
method: str = data["method"]
231+
opts: CallOptions = {}
231232
if self._pinned_version is not None:
232233
params = data.setdefault("params", {})
233234
envelope_meta = params.setdefault("_meta", {})
@@ -238,8 +239,6 @@ async def send_request(
238239
envelope_meta[CLIENT_CAPABILITIES_META_KEY] = self._build_capabilities().model_dump(
239240
by_alias=True, mode="json", exclude_none=True
240241
)
241-
opts: CallOptions = {}
242-
if self._pinned_version is not None:
243242
# Stateless pinned mode: disconnect-as-cancel is the spec mechanism, so the
244243
# dispatcher must not emit notifications/cancelled when the caller abandons.
245244
opts["cancel_on_abandon"] = False

src/mcp/client/streamable_http.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
from mcp.shared._context_streams import ContextReceiveStream, ContextSendStream, create_context_streams
2222
from mcp.shared._httpx_utils import create_mcp_http_client
2323
from mcp.shared.message import ClientMessageMetadata, SessionMessage
24+
from mcp.shared.version import StatelessProtocolVersion, is_version_at_least
2425
from mcp.types import (
2526
INTERNAL_ERROR,
2627
INVALID_REQUEST,
@@ -60,7 +61,7 @@
6061

6162

6263
def _encode_header_value(value: str) -> str:
63-
if _HEADER_SAFE.fullmatch(value) and not _B64_SENTINEL.fullmatch(value):
64+
if _HEADER_SAFE.fullmatch(value) and value == value.strip(" ") and not _B64_SENTINEL.fullmatch(value):
6465
return value
6566
return f"=?base64?{base64.b64encode(value.encode('utf-8')).decode('ascii')}?="
6667

@@ -107,11 +108,13 @@ def _per_message_headers(self, message: JSONRPCMessage) -> dict[str, str]:
107108
MCP-Protocol-Version is not emitted here — `_prepare_headers()` already adds it
108109
from `self.protocol_version` for every request.
109110
"""
110-
if self.protocol_version is None or self.protocol_version < "2026-07-28":
111+
if self.protocol_version is None or not is_version_at_least(self.protocol_version, "2026-07-28"):
111112
return {}
112113
if not isinstance(message, JSONRPCRequest | JSONRPCNotification):
113114
return {}
114115
headers: dict[str, str] = {MCP_METHOD: message.method}
116+
# TODO: Mcp-Name is also REQUIRED for prompts/get (params.name) and resources/read
117+
# (params.uri); a method->param-key map replaces this gate when those land.
115118
if (
116119
isinstance(message, JSONRPCRequest)
117120
and message.method == "tools/call"
@@ -564,7 +567,7 @@ async def streamable_http_client(
564567
*,
565568
http_client: httpx.AsyncClient | None = None,
566569
terminate_on_close: bool = True,
567-
protocol_version: str | None = None,
570+
protocol_version: StatelessProtocolVersion | None = None,
568571
) -> AsyncGenerator[TransportStreams, None]:
569572
"""Client transport for StreamableHTTP.
570573

src/mcp/server/session.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -91,8 +91,11 @@ async def send_request(
9191
# Fail fast instead of parking forever on a response that cannot
9292
# arrive; matches `Connection.send_raw_request`.
9393
raise NoBackChannelError(data["method"])
94-
# TODO: _related_request_id is not on the Dispatcher Protocol; either
95-
# add it there or refactor ServerSession once the legacy path is compat-only.
94+
# TODO: _related_request_id is not on the Dispatcher Protocol (and must not
95+
# be — it's transport-specific). The fix is to give `ctx.session` a per-request
96+
# Outbound (the DispatchContext, which threads its own request_id) alongside
97+
# the connection-level one, with `related_request_id` as the selector; that
98+
# belongs with the ServerSession/Context rework, not here.
9699
result = cast(
97100
"dict[str, Any]",
98101
await self._dispatcher.send_raw_request(

src/mcp/shared/version.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,13 @@
77
ordering questions go through KNOWN_PROTOCOL_VERSIONS.
88
"""
99

10-
from typing import Final
10+
from typing import Final, Literal
1111

1212
from mcp.types import LATEST_PROTOCOL_VERSION
1313

14+
StatelessProtocolVersion = Literal["2026-07-28"]
15+
"""Protocol revisions that use the stateless per-request envelope (no `initialize`)."""
16+
1417
KNOWN_PROTOCOL_VERSIONS: Final[tuple[str, ...]] = (
1518
"2024-11-05",
1619
"2025-03-26",

tests/client/test_session.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1400,6 +1400,19 @@ async def notify(self, method: str, params: Mapping[str, Any] | None) -> None:
14001400
assert session._task_group is None
14011401

14021402

1403+
@pytest.mark.anyio
1404+
async def test_initialize_on_a_pinned_session_raises_before_any_frame_is_sent():
1405+
"""A session pinned to the 2026-07-28 stateless protocol rejects ``initialize()`` locally.
1406+
1407+
The 2026-07-28 lifecycle replaces the initialize handshake with a per-request ``_meta``
1408+
envelope, so calling ``initialize()`` on a pinned session is a programmer error and raises
1409+
immediately rather than reaching the wire.
1410+
"""
1411+
async with raw_client_session(protocol_version="2026-07-28") as (session, _send, _recv):
1412+
with pytest.raises(RuntimeError, match="pinned to a stateless"):
1413+
await session.initialize()
1414+
1415+
14031416
@pytest.mark.anyio
14041417
async def test_send_notification_after_close_is_dropped_silently():
14051418
"""Post-close `send_notification` is fire-and-forget: the notification is dropped,

tests/client/test_streamable_http.py

Lines changed: 54 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,15 @@
77
"""
88

99
import base64
10+
import json
1011

12+
import anyio
13+
import httpx
1114
import pytest
1215
from inline_snapshot import snapshot
1316

14-
from mcp.client.streamable_http import StreamableHTTPTransport, _encode_header_value
17+
from mcp.client import ClientSession
18+
from mcp.client.streamable_http import StreamableHTTPTransport, _encode_header_value, streamable_http_client
1519
from mcp.types import JSONRPCMessage, JSONRPCNotification, JSONRPCRequest, JSONRPCResponse
1620

1721

@@ -61,7 +65,10 @@ def test_per_message_headers_are_empty_for_legacy_or_unpinned_transport(protocol
6165
("raw", "expected", "wrapped"),
6266
[
6367
("add", snapshot("add"), False),
68+
("", snapshot(""), False),
6469
("tool with spaces", snapshot("tool with spaces"), False),
70+
(" add", snapshot("=?base64?IGFkZA==?="), True),
71+
("add ", snapshot("=?base64?YWRkIA==?="), True),
6572
("résumé", snapshot("=?base64?csOpc3Vtw6k=?="), True),
6673
("a\r\nb", snapshot("=?base64?YQ0KYg==?="), True),
6774
("=?base64?Zm9v?=", snapshot("=?base64?PT9iYXNlNjQ/Wm05dj89?="), True),
@@ -70,10 +77,12 @@ def test_per_message_headers_are_empty_for_legacy_or_unpinned_transport(protocol
7077
def test_mcp_name_header_values_are_base64_wrapped_when_unsafe_for_an_http_field(
7178
raw: str, expected: str, wrapped: bool
7279
) -> None:
73-
"""Printable-ASCII names pass verbatim; CR/LF, non-ASCII, and sentinel-shaped names are wrapped.
80+
"""Printable-ASCII names pass verbatim; CR/LF, non-ASCII, edge-whitespace, and sentinel-shaped names are wrapped.
7481
7582
The ``=?base64?...?=`` sentinel is the spec's RFC 7230 safety gate for the ``Mcp-Name`` header.
76-
Wrapped values round-trip through base64 so the server can recover the original name.
83+
Wrapped values round-trip through base64 so the server can recover the original name. A leading
84+
or trailing space is wrapped because RFC 7230 forbids it in field-values (h11 rejects on real
85+
transports); an empty value is allowed and passes verbatim.
7786
"""
7887
encoded = _encode_header_value(raw)
7988
assert encoded == expected
@@ -84,6 +93,48 @@ def test_mcp_name_header_values_are_base64_wrapped_when_unsafe_for_an_http_field
8493
assert encoded == raw
8594

8695

96+
@pytest.mark.anyio
97+
async def test_pinned_transport_ignores_returned_session_id_and_never_opens_get_or_delete() -> None:
98+
"""A server-issued ``Mcp-Session-Id`` never reaches a pinned client's wire: only POSTs are sent.
99+
100+
The session-id capture, the standalone GET listening stream, and the DELETE-on-close are all
101+
gated implicitly: a pinned ``ClientSession`` never sends ``initialize`` (no InitializeResult to
102+
capture an id from) and never sends ``notifications/initialized`` (which is what triggers the
103+
standalone GET), so even when a misbehaving peer volunteers a session id on every response the
104+
recorded log stays POST-only and no request echoes the id back. The successful ``tools/call``
105+
triggers the client's implicit ``tools/list`` output-schema fetch so there is a second POST
106+
after the id was offered.
107+
"""
108+
recorded: list[httpx.Request] = []
109+
110+
def handler(request: httpx.Request) -> httpx.Response:
111+
recorded.append(request)
112+
body = json.loads(request.content)
113+
if body["method"] == "tools/list":
114+
result: dict[str, object] = {
115+
"tools": [{"name": "add", "inputSchema": {"type": "object"}}],
116+
"resultType": "complete",
117+
"ttlMs": 0,
118+
"cacheScope": "public",
119+
}
120+
else:
121+
result = {"content": [{"type": "text", "text": "5"}], "isError": False, "resultType": "complete"}
122+
return httpx.Response(
123+
200, json={"jsonrpc": "2.0", "id": body["id"], "result": result}, headers={"mcp-session-id": "srv-123"}
124+
)
125+
126+
with anyio.fail_after(5):
127+
async with (
128+
httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http,
129+
streamable_http_client("http://test/mcp", http_client=http, protocol_version="2026-07-28") as (read, write),
130+
ClientSession(read, write, protocol_version="2026-07-28") as session,
131+
):
132+
await session.call_tool("add", {"a": 2, "b": 3})
133+
134+
assert [r.method for r in recorded] == snapshot(["POST", "POST"])
135+
assert all("mcp-session-id" not in r.headers for r in recorded)
136+
137+
87138
def test_constructor_pin_is_not_overwritten_by_an_initialize_result() -> None:
88139
"""A protocol_version passed at construction wins over the InitializeResult snoop."""
89140
transport = StreamableHTTPTransport("http://test/mcp", protocol_version="2026-07-28")

tests/interaction/_requirements.py

Lines changed: 2 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -331,6 +331,7 @@ def __post_init__(self) -> None:
331331
source=f"{SPEC_2026_BASE_URL}/basic/lifecycle#stateless-operation",
332332
behavior="A ClientSession pinned to 2026-07-28 rejects initialize() before any frame is sent.",
333333
added_in="2026-07-28",
334+
deferred="covered by a tests/client/ unit test; not observable as an interaction",
334335
),
335336
"lifecycle:stateless:caller-meta-preserved": Requirement(
336337
source=f"{SPEC_2026_BASE_URL}/basic/lifecycle#stateless-operation",
@@ -340,13 +341,6 @@ def __post_init__(self) -> None:
340341
),
341342
added_in="2026-07-28",
342343
),
343-
"lifecycle:stateless:unpinned-legacy-wire": Requirement(
344-
source=f"{SPEC_2026_BASE_URL}/basic/versioning",
345-
behavior=(
346-
"An unpinned session that negotiates an earlier protocol version emits no 2026-07-28 "
347-
"vocabulary on any JSON-RPC frame in either direction."
348-
),
349-
),
350344
# ═══════════════════════════════════════════════════════════════════════════
351345
# Protocol primitives: cancellation, timeout, progress, errors, _meta
352346
# ═══════════════════════════════════════════════════════════════════════════
@@ -3137,16 +3131,6 @@ def __post_init__(self) -> None:
31373131
removed_in="2026-07-28",
31383132
note="removed in 2026-07-28 (SEP-2567); session DELETE removed with Mcp-Session-Id, no replacement.",
31393133
),
3140-
"client-transport:http:body-derived-headers": Requirement(
3141-
source=f"{SPEC_2026_BASE_URL}/basic/transports#stateless-request-headers",
3142-
behavior=(
3143-
"An envelope-bearing request body yields MCP-Protocol-Version, Mcp-Method, and (for tools/call) "
3144-
"Mcp-Name headers on the outgoing HTTP request; a body without the envelope yields none."
3145-
),
3146-
added_in="2026-07-28",
3147-
transports=("streamable-http",),
3148-
note="Only observable over streamable HTTP: headers are derived from the body envelope at the transport seam.",
3149-
),
31503134
"client-transport:http:stateless-ignores-session-id": Requirement(
31513135
source=f"{SPEC_2026_BASE_URL}/basic/transports#stateless-request-headers",
31523136
behavior=(
@@ -3156,6 +3140,7 @@ def __post_init__(self) -> None:
31563140
added_in="2026-07-28",
31573141
transports=("streamable-http",),
31583142
note="Only observable over streamable HTTP: session-id, GET stream and DELETE are streamable-HTTP mechanics.",
3143+
deferred="defensive against a misbehaving peer; covered by a tests/client/ unit test",
31593144
),
31603145
# ═══════════════════════════════════════════════════════════════════════════
31613146
# Client auth

0 commit comments

Comments
 (0)