Skip to content

Commit 3afe0f0

Browse files
committed
Tighten the consolidated 2026-07-28 interaction tests
- Prove the envelope is stamped when the caller passes no _meta by snapshotting the implicit tools/list body in the capstone - Give _server() an on_meta hook so the capstone reuses it instead of duplicating its handlers - Restore the wire-emptiness check on the pinned-initialize-raises unit test (buffer-used == 0 after the raise) - Restore lifecycle:stateless:unpinned-legacy-wire (deferred) and client-transport:http:body-derived-headers (stacked on the capstone) in the requirements ledger - Drop the redundant strip(" ") arg in _encode_header_value
1 parent 194f225 commit 3afe0f0

4 files changed

Lines changed: 44 additions & 18 deletions

File tree

src/mcp/client/streamable_http.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@
6161

6262

6363
def _encode_header_value(value: str) -> str:
64-
if _HEADER_SAFE.fullmatch(value) and value == value.strip(" ") and not _B64_SENTINEL.fullmatch(value):
64+
if _HEADER_SAFE.fullmatch(value) and value == value.strip() and not _B64_SENTINEL.fullmatch(value):
6565
return value
6666
return f"=?base64?{base64.b64encode(value.encode('utf-8')).decode('ascii')}?="
6767

tests/client/test_session.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1408,9 +1408,10 @@ async def test_initialize_on_a_pinned_session_raises_before_any_frame_is_sent():
14081408
envelope, so calling ``initialize()`` on a pinned session is a programmer error and raises
14091409
immediately rather than reaching the wire.
14101410
"""
1411-
async with raw_client_session(protocol_version="2026-07-28") as (session, _send, _recv):
1411+
async with raw_client_session(protocol_version="2026-07-28") as (session, _send, from_client):
14121412
with pytest.raises(RuntimeError, match="pinned to a stateless"):
14131413
await session.initialize()
1414+
assert from_client.statistics().current_buffer_used == 0
14141415

14151416

14161417
@pytest.mark.anyio

tests/interaction/_requirements.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -341,6 +341,17 @@ def __post_init__(self) -> None:
341341
),
342342
added_in="2026-07-28",
343343
),
344+
"lifecycle:stateless:unpinned-legacy-wire": Requirement(
345+
source=f"{SPEC_2026_BASE_URL}/basic/versioning",
346+
behavior=(
347+
"An unpinned session that negotiates an earlier protocol version emits no 2026-07-28 "
348+
"vocabulary on any JSON-RPC frame in either direction."
349+
),
350+
deferred=(
351+
"bare-ClientSession seam; the high-level Client + HTTP-seam scan in "
352+
"hosting:http:legacy-no-modern-vocabulary covers the same vocabulary set"
353+
),
354+
),
344355
# ═══════════════════════════════════════════════════════════════════════════
345356
# Protocol primitives: cancellation, timeout, progress, errors, _meta
346357
# ═══════════════════════════════════════════════════════════════════════════
@@ -3131,6 +3142,16 @@ def __post_init__(self) -> None:
31313142
removed_in="2026-07-28",
31323143
note="removed in 2026-07-28 (SEP-2567); session DELETE removed with Mcp-Session-Id, no replacement.",
31333144
),
3145+
"client-transport:http:body-derived-headers": Requirement(
3146+
source=f"{SPEC_2026_BASE_URL}/basic/transports#stateless-request-headers",
3147+
behavior=(
3148+
"An envelope-bearing request body yields MCP-Protocol-Version, Mcp-Method, and (for tools/call) "
3149+
"Mcp-Name headers on the outgoing HTTP request; a body without the envelope yields none."
3150+
),
3151+
added_in="2026-07-28",
3152+
transports=("streamable-http",),
3153+
note="Only observable over streamable HTTP: headers are derived from the body envelope at the transport seam.",
3154+
),
31343155
"client-transport:http:stateless-ignores-session-id": Requirement(
31353156
source=f"{SPEC_2026_BASE_URL}/basic/transports#stateless-request-headers",
31363157
behavior=(

tests/interaction/transports/test_hosting_http_modern.py

Lines changed: 20 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
"""
99

1010
import json
11+
from collections.abc import Callable
12+
from typing import Any
1113

1214
import anyio
1315
import httpx
@@ -63,15 +65,19 @@ def _meta_envelope() -> dict[str, object]:
6365
}
6466

6567

66-
def _server() -> Server:
68+
def _server(*, on_meta: Callable[[dict[str, Any]], None] | None = None) -> Server:
6769
"""A low-level server with one ``add`` tool for the raw-httpx tests below."""
6870

6971
async def list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult:
70-
raise NotImplementedError
72+
tool = Tool(name="add", input_schema={"type": "object"})
73+
return ListToolsResult(tools=[tool], ttl_ms=0, cache_scope="public")
7174

7275
async def call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult:
7376
assert params.name == "add"
7477
assert params.arguments is not None
78+
if on_meta is not None:
79+
assert ctx.meta is not None
80+
on_meta(dict(ctx.meta))
7581
return CallToolResult(content=[TextContent(text=str(params.arguments["a"] + params.arguments["b"]))])
7682

7783
return Server("modern", on_list_tools=list_tools, on_call_tool=call_tool)
@@ -195,6 +201,7 @@ async def call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) ->
195201
@requirement("hosting:http:modern:tools-call-stateless")
196202
@requirement("lifecycle:stateless:request-envelope")
197203
@requirement("lifecycle:stateless:caller-meta-preserved")
204+
@requirement("client-transport:http:body-derived-headers")
198205
async def test_pinned_client_stateless_tools_call_round_trips_against_the_modern_entry() -> None:
199206
"""First end-to-end exercise of the 2026-07-28 stateless request style: SDK client to SDK server.
200207
@@ -211,20 +218,8 @@ async def test_pinned_client_stateless_tools_call_round_trips_against_the_modern
211218
client's implicit ``tools/list`` output-schema fetch (see ``client:output-schema:auto-list``),
212219
both of which must satisfy the stateless contract.
213220
"""
214-
observed_metas: list[dict[str, object]] = []
215-
216-
async def list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult:
217-
tool = Tool(name="add", input_schema={"type": "object"})
218-
return ListToolsResult(tools=[tool], ttl_ms=0, cache_scope="public")
219-
220-
async def call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult:
221-
assert params.name == "add"
222-
assert params.arguments is not None
223-
assert ctx.meta is not None
224-
observed_metas.append(dict(ctx.meta))
225-
return CallToolResult(content=[TextContent(text=str(params.arguments["a"] + params.arguments["b"]))])
226-
227-
server = Server("modern", on_list_tools=list_tools, on_call_tool=call_tool)
221+
observed_metas: list[dict[str, Any]] = []
222+
server = _server(on_meta=observed_metas.append)
228223

229224
requests: list[httpx.Request] = []
230225
responses: list[httpx.Response] = []
@@ -273,6 +268,15 @@ async def on_response(response: httpx.Response) -> None:
273268
"io.modelcontextprotocol/clientCapabilities": {},
274269
}
275270
)
271+
# The implicit tools/list carries the envelope but no caller meta: proves the envelope is
272+
# stamped on every request, not just on requests where the caller passed meta=.
273+
assert bodies[1]["params"]["_meta"] == snapshot(
274+
{
275+
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
276+
"io.modelcontextprotocol/clientInfo": {"name": "e2e-client", "version": "1.0.0"},
277+
"io.modelcontextprotocol/clientCapabilities": {},
278+
}
279+
)
276280

277281
# The server handler observed the same merged _meta on ctx.meta.
278282
assert observed_metas == [bodies[0]["params"]["_meta"]]

0 commit comments

Comments
 (0)