|
| 1 | +"""Stateless lifecycle at protocol version 2026-07-28, driven through a bare ClientSession. |
| 2 | +
|
| 3 | +Under the 2026-07-28 lifecycle the initialize handshake is replaced by a per-request envelope: |
| 4 | +every request carries the protocol version, client info, and client capabilities under |
| 5 | +``params._meta`` and the server never sees an ``initialize`` frame. These tests pin the session |
| 6 | +to that version and observe the outgoing JSON-RPC frame directly, so they drop below the |
| 7 | +``connect`` fixture to a bare ClientSession over in-process memory streams. No 2026-aware Server |
| 8 | +exists yet, so the receiving side is a scripted peer that hand-builds the wire response — reserve |
| 9 | +this pattern for behaviour no real server can be made to produce. |
| 10 | +""" |
| 11 | + |
| 12 | +from contextlib import nullcontext |
| 13 | + |
| 14 | +import anyio |
| 15 | +import pytest |
| 16 | +from inline_snapshot import snapshot |
| 17 | + |
| 18 | +from mcp.client import ClientSession |
| 19 | +from mcp.shared.memory import MessageStream, create_client_server_memory_streams |
| 20 | +from mcp.shared.message import SessionMessage |
| 21 | +from mcp.types import ( |
| 22 | + CallToolResult, |
| 23 | + Implementation, |
| 24 | + InitializeResult, |
| 25 | + JSONRPCNotification, |
| 26 | + JSONRPCRequest, |
| 27 | + JSONRPCResponse, |
| 28 | + ListToolsResult, |
| 29 | + ServerCapabilities, |
| 30 | +) |
| 31 | +from tests.interaction._helpers import RecordingTransport |
| 32 | +from tests.interaction._modern_vocab import MODERN_BODY_TOKENS |
| 33 | +from tests.interaction._requirements import requirement |
| 34 | + |
| 35 | +pytestmark = pytest.mark.anyio |
| 36 | + |
| 37 | + |
| 38 | +@requirement("lifecycle:stateless:request-envelope") |
| 39 | +async def test_pinned_session_stamps_the_envelope_meta_on_every_request_and_never_initializes() -> None: |
| 40 | + """A pinned session's first request is the feature request itself, carrying the three-key envelope. |
| 41 | +
|
| 42 | + The scripted peer asserts the only frame on the wire is ``tools/list`` (no ``initialize``, no |
| 43 | + ``notifications/initialized``) and answers with a hand-built 2026-07-28 result; the test then |
| 44 | + snapshots the captured ``params._meta``. |
| 45 | + """ |
| 46 | + received: list[JSONRPCRequest] = [] |
| 47 | + |
| 48 | + async def scripted_server(streams: MessageStream) -> None: |
| 49 | + server_read, server_write = streams |
| 50 | + message = await server_read.receive() |
| 51 | + assert isinstance(message, SessionMessage) |
| 52 | + request = message.message |
| 53 | + assert isinstance(request, JSONRPCRequest) |
| 54 | + assert request.method == "tools/list" |
| 55 | + received.append(request) |
| 56 | + result = ListToolsResult(tools=[], cache_scope="public", ttl_ms=0) |
| 57 | + await server_write.send( |
| 58 | + SessionMessage( |
| 59 | + JSONRPCResponse( |
| 60 | + jsonrpc="2.0", |
| 61 | + id=request.id, |
| 62 | + # Serialized exactly as a real server serializes results onto the wire. |
| 63 | + result=result.model_dump(by_alias=True, mode="json", exclude_none=True), |
| 64 | + ) |
| 65 | + ) |
| 66 | + ) |
| 67 | + |
| 68 | + async with ( |
| 69 | + create_client_server_memory_streams() as ((client_read, client_write), server_streams), |
| 70 | + anyio.create_task_group() as tg, |
| 71 | + ClientSession( |
| 72 | + client_read, |
| 73 | + client_write, |
| 74 | + client_info=Implementation(name="pin-client", version="1.0.0"), |
| 75 | + protocol_version="2026-07-28", |
| 76 | + ) as session, |
| 77 | + ): |
| 78 | + tg.start_soon(scripted_server, server_streams) |
| 79 | + with anyio.fail_after(5): |
| 80 | + result = await session.list_tools() |
| 81 | + assert isinstance(result, ListToolsResult) |
| 82 | + |
| 83 | + assert len(received) == 1 |
| 84 | + only = received[0] |
| 85 | + assert only.params is not None |
| 86 | + assert only.params["_meta"] == snapshot( |
| 87 | + { |
| 88 | + "io.modelcontextprotocol/protocolVersion": "2026-07-28", |
| 89 | + "io.modelcontextprotocol/clientInfo": {"name": "pin-client", "version": "1.0.0"}, |
| 90 | + "io.modelcontextprotocol/clientCapabilities": {}, |
| 91 | + } |
| 92 | + ) |
| 93 | + |
| 94 | + |
| 95 | +@requirement("lifecycle:stateless:no-initialize") |
| 96 | +async def test_initialize_on_a_pinned_session_is_rejected_before_any_frame_is_sent() -> None: |
| 97 | + """``initialize()`` on a pinned session raises immediately, never reaching the wire. |
| 98 | +
|
| 99 | + After the rejection the client's send stream is closed and the server-side read drains to |
| 100 | + EndOfStream with no buffered frame, proving the guard fired before any write. |
| 101 | + """ |
| 102 | + async with create_client_server_memory_streams() as ((client_read, client_write), (server_read, _server_write)): |
| 103 | + async with ClientSession(client_read, client_write, protocol_version="2026-07-28") as session: |
| 104 | + with anyio.fail_after(5): |
| 105 | + with pytest.raises(RuntimeError) as exc_info: |
| 106 | + await session.initialize() |
| 107 | + assert str(exc_info.value) == snapshot( |
| 108 | + "initialize() must not be called on a session pinned to a stateless protocol version" |
| 109 | + ) |
| 110 | + # Nothing left the client: closing the sender turns an empty buffer into EndOfStream. |
| 111 | + await client_write.aclose() |
| 112 | + with anyio.fail_after(5): |
| 113 | + with pytest.raises(anyio.EndOfStream): |
| 114 | + await server_read.receive() |
| 115 | + |
| 116 | + |
| 117 | +@requirement("lifecycle:stateless:caller-meta-preserved") |
| 118 | +async def test_caller_supplied_meta_is_preserved_under_the_envelope_merge() -> None: |
| 119 | + """A caller's ``meta=`` keys survive the pinned session's envelope stamp on the same ``_meta`` object. |
| 120 | +
|
| 121 | + The envelope merge is additive, so a caller-supplied key sits alongside the three |
| 122 | + ``io.modelcontextprotocol/*`` keys rather than being overwritten. The scripted peer captures the |
| 123 | + single ``tools/call`` frame and answers with an ``is_error`` result so the client skips its |
| 124 | + implicit output-schema fetch; the test then snapshots the captured ``params._meta``. |
| 125 | + """ |
| 126 | + received: list[JSONRPCRequest] = [] |
| 127 | + |
| 128 | + async def scripted_server(streams: MessageStream) -> None: |
| 129 | + server_read, server_write = streams |
| 130 | + message = await server_read.receive() |
| 131 | + assert isinstance(message, SessionMessage) |
| 132 | + request = message.message |
| 133 | + assert isinstance(request, JSONRPCRequest) |
| 134 | + assert request.method == "tools/call" |
| 135 | + received.append(request) |
| 136 | + result = CallToolResult(content=[], is_error=True) |
| 137 | + await server_write.send( |
| 138 | + SessionMessage( |
| 139 | + JSONRPCResponse( |
| 140 | + jsonrpc="2.0", |
| 141 | + id=request.id, |
| 142 | + # Serialized exactly as a real server serializes results onto the wire. |
| 143 | + result=result.model_dump(by_alias=True, mode="json", exclude_none=True), |
| 144 | + ) |
| 145 | + ) |
| 146 | + ) |
| 147 | + |
| 148 | + async with ( |
| 149 | + create_client_server_memory_streams() as ((client_read, client_write), server_streams), |
| 150 | + anyio.create_task_group() as tg, |
| 151 | + ClientSession( |
| 152 | + client_read, |
| 153 | + client_write, |
| 154 | + client_info=Implementation(name="pin-client", version="1.0.0"), |
| 155 | + protocol_version="2026-07-28", |
| 156 | + ) as session, |
| 157 | + ): |
| 158 | + tg.start_soon(scripted_server, server_streams) |
| 159 | + with anyio.fail_after(5): |
| 160 | + result = await session.call_tool("add", {"a": 2, "b": 3}, meta={"custom-key": "x"}) |
| 161 | + assert isinstance(result, CallToolResult) |
| 162 | + |
| 163 | + assert len(received) == 1 |
| 164 | + only = received[0] |
| 165 | + assert only.params is not None |
| 166 | + assert only.params["_meta"] == snapshot( |
| 167 | + { |
| 168 | + "custom-key": "x", |
| 169 | + "io.modelcontextprotocol/protocolVersion": "2026-07-28", |
| 170 | + "io.modelcontextprotocol/clientInfo": {"name": "pin-client", "version": "1.0.0"}, |
| 171 | + "io.modelcontextprotocol/clientCapabilities": {}, |
| 172 | + } |
| 173 | + ) |
| 174 | + |
| 175 | + |
| 176 | +@requirement("lifecycle:stateless:unpinned-legacy-wire") |
| 177 | +async def test_unpinned_session_round_trip_carries_no_modern_protocol_vocabulary() -> None: |
| 178 | + """An unpinned session's handshake-plus-request emits no 2026-07-28 vocabulary on any frame. |
| 179 | +
|
| 180 | + The JSON-RPC-seam complement to ``test_legacy_wire.py`` (which records the HTTP seam): a |
| 181 | + ``RecordingTransport`` wrapped around the client side of the in-process memory streams captures |
| 182 | + every frame in either direction, the scripted peer answers ``initialize`` at ``2025-11-25`` then |
| 183 | + ``tools/list``, and every captured frame body is scanned for :data:`MODERN_BODY_TOKENS` so any |
| 184 | + leak of the envelope keys, the result-envelope fields, or the version literal onto the legacy |
| 185 | + session path fails here. |
| 186 | + """ |
| 187 | + |
| 188 | + async def scripted_server(streams: MessageStream) -> None: |
| 189 | + server_read, server_write = streams |
| 190 | + init = await server_read.receive() |
| 191 | + assert isinstance(init, SessionMessage) |
| 192 | + assert isinstance(init.message, JSONRPCRequest) |
| 193 | + assert init.message.method == "initialize" |
| 194 | + result = InitializeResult( |
| 195 | + protocol_version="2025-11-25", |
| 196 | + capabilities=ServerCapabilities(), |
| 197 | + server_info=Implementation(name="legacy-server", version="0.0.0"), |
| 198 | + ) |
| 199 | + await server_write.send( |
| 200 | + SessionMessage( |
| 201 | + JSONRPCResponse( |
| 202 | + jsonrpc="2.0", |
| 203 | + id=init.message.id, |
| 204 | + # Serialized exactly as a real server serializes results onto the wire. |
| 205 | + result=result.model_dump(by_alias=True, mode="json", exclude_none=True), |
| 206 | + ) |
| 207 | + ) |
| 208 | + ) |
| 209 | + initialized = await server_read.receive() |
| 210 | + assert isinstance(initialized, SessionMessage) |
| 211 | + assert isinstance(initialized.message, JSONRPCNotification) |
| 212 | + listing = await server_read.receive() |
| 213 | + assert isinstance(listing, SessionMessage) |
| 214 | + assert isinstance(listing.message, JSONRPCRequest) |
| 215 | + assert listing.message.method == "tools/list" |
| 216 | + await server_write.send( |
| 217 | + SessionMessage(JSONRPCResponse(jsonrpc="2.0", id=listing.message.id, result={"tools": []})) |
| 218 | + ) |
| 219 | + |
| 220 | + async with create_client_server_memory_streams() as (client_streams, server_streams): |
| 221 | + recording = RecordingTransport(nullcontext(client_streams)) |
| 222 | + async with ( |
| 223 | + anyio.create_task_group() as tg, |
| 224 | + recording as (client_read, client_write), |
| 225 | + ClientSession(client_read, client_write) as session, |
| 226 | + ): |
| 227 | + tg.start_soon(scripted_server, server_streams) |
| 228 | + with anyio.fail_after(5): |
| 229 | + await session.initialize() |
| 230 | + result = await session.list_tools() |
| 231 | + assert isinstance(result, ListToolsResult) |
| 232 | + |
| 233 | + frames = list(recording.sent) + [m for m in recording.received if isinstance(m, SessionMessage)] |
| 234 | + methods = [m.message.method for m in recording.sent if isinstance(m.message, JSONRPCRequest | JSONRPCNotification)] |
| 235 | + assert methods == snapshot(["initialize", "notifications/initialized", "tools/list"]) |
| 236 | + bodies = [m.message.model_dump_json(by_alias=True, exclude_none=True) for m in frames] |
| 237 | + leaked = sorted({token for token in MODERN_BODY_TOKENS for body in bodies if token in body}) |
| 238 | + assert leaked == [] |
0 commit comments