Skip to content

Commit 2d62124

Browse files
committed
test: prove json-response Content-Type and explicit resumption-token API at the wire
1 parent ca0ba11 commit 2d62124

5 files changed

Lines changed: 111 additions & 5 deletions

File tree

src/mcp/server/streamable_http.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1042,7 +1042,7 @@ async def message_router():
10421042
yield read_stream, write_stream
10431043
finally:
10441044
for stream_id in list(self._request_streams.keys()):
1045-
await self._clean_up_memory_streams(stream_id) # pragma: no cover
1045+
await self._clean_up_memory_streams(stream_id)
10461046
self._request_streams.clear()
10471047

10481048
# Clean up the read and write streams

tests/interaction/_connect.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,7 @@ async def mounted_app(
150150
server: Server | MCPServer,
151151
*,
152152
stateless_http: bool = False,
153+
json_response: bool = False,
153154
event_store: EventStore | None = None,
154155
retry_interval: int | None = None,
155156
transport_security: TransportSecuritySettings | None = NO_DNS_REBINDING_PROTECTION,
@@ -174,6 +175,7 @@ async def mounted_app(
174175
lowlevel = server._lowlevel_server if isinstance(server, MCPServer) else server
175176
app = lowlevel.streamable_http_app(
176177
stateless_http=stateless_http,
178+
json_response=json_response,
177179
event_store=event_store,
178180
retry_interval=retry_interval,
179181
transport_security=transport_security,

tests/interaction/transports/test_hosting_http.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,30 @@ async def test_protocol_version_header_is_validated() -> None:
179179
assert defaulted.status_code == 202
180180

181181

182+
@requirement("hosting:http:json-response-mode")
183+
async def test_json_response_mode_answers_with_application_json_not_sse() -> None:
184+
"""With JSON response mode enabled, request POSTs are answered with a single application/json body.
185+
186+
Asserted at the wire level because the SDK client parses either representation, so a
187+
Client-driven round trip cannot distinguish a JSON response from an SSE one.
188+
"""
189+
async with mounted_app(_server(), json_response=True) as (http, _):
190+
initialized = await http.post("/mcp", json=initialize_body(), headers=base_headers())
191+
session_id = initialized.headers["mcp-session-id"]
192+
ping = await http.post(
193+
"/mcp",
194+
json={"jsonrpc": "2.0", "id": 2, "method": "ping"},
195+
headers=base_headers(session_id=session_id),
196+
)
197+
198+
assert initialized.status_code == 200
199+
assert initialized.headers["content-type"].split(";", 1)[0] == "application/json"
200+
assert JSONRPCResponse.model_validate(initialized.json()).id == 1
201+
assert ping.status_code == 200
202+
assert ping.headers["content-type"].split(";", 1)[0] == "application/json"
203+
assert JSONRPCResponse.model_validate(ping.json()).id == 2
204+
205+
182206
@requirement("hosting:http:notifications-202")
183207
async def test_notification_post_returns_202_with_no_body() -> None:
184208
"""A POST containing only a notification (no request ID) returns 202 Accepted with no body."""

tests/interaction/transports/test_hosting_resume.py

Lines changed: 84 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,14 @@
1717
from httpx_sse import EventSource, ServerSentEvent
1818
from inline_snapshot import snapshot
1919

20+
from mcp.client.session import ClientSession
21+
from mcp.client.streamable_http import streamable_http_client
2022
from mcp.server.mcpserver import Context, MCPServer
23+
from mcp.shared.message import ClientMessageMetadata
2124
from mcp.types import (
25+
LATEST_PROTOCOL_VERSION,
26+
CallToolRequest,
27+
CallToolRequestParams,
2228
CallToolResult,
2329
JSONRPCNotification,
2430
JSONRPCRequest,
@@ -28,6 +34,7 @@
2834
jsonrpc_message_adapter,
2935
)
3036
from tests.interaction._connect import (
37+
BASE_URL,
3138
base_headers,
3239
connect_over_streamable_http,
3340
initialize_via_http,
@@ -229,13 +236,12 @@ async def hold(ctx: Context) -> str:
229236
await finished.wait()
230237

231238

232-
# This test intentionally carries every resumability requirement: the close-then-resume
233-
# scenario is indivisible, so splitting it would mean six near-identical bodies.
239+
# This test intentionally carries every automatic-reconnection requirement: the
240+
# close-then-resume scenario is indivisible, so splitting it would mean five near-identical bodies.
234241
@requirement("hosting:resume:close-stream")
235242
@requirement("transport:streamable-http:resumability")
236243
@requirement("client-transport:http:reconnect-post-priming")
237244
@requirement("client-transport:http:reconnect-retry-value")
238-
@requirement("client-transport:http:resume-stream-api")
239245
@requirement("flow:resume:tool-call-resumption-token")
240246
async def test_a_call_whose_stream_the_server_closes_is_resumed_by_the_client() -> None:
241247
"""A server-closed request stream is reconnected by the client and the call completes.
@@ -288,3 +294,78 @@ async def call() -> None:
288294
[CallToolResult(content=[TextContent(text="resumed")], structured_content={"result": "resumed"})]
289295
)
290296
assert received == snapshot(["before close", "after close"])
297+
298+
299+
@requirement("client-transport:http:resume-stream-api")
300+
async def test_a_captured_resumption_token_replays_missed_messages_on_a_new_connection() -> None:
301+
"""A resumption token captured via on_resumption_token_update on one connection lets a fresh
302+
connection retrieve the messages it missed by passing resumption_token to send_request.
303+
304+
This is the explicit ClientMessageMetadata API, distinct from the automatic reconnection the
305+
previous test covers: the transport dispatches a resumption_token request as a GET with
306+
Last-Event-ID instead of POSTing the body, and remaps the replayed response onto the new
307+
request's id. Client.call_tool does not expose ClientMessageMetadata, so the test drives a
308+
bare ClientSession via session.send_request -- the sanctioned drop-down for behaviour Client
309+
cannot express. The second connection carries the original session id but does not initialize
310+
(the server-side session already is), modelling a caller that resumes after a process restart.
311+
"""
312+
captured: list[str] = []
313+
received: list[object] = []
314+
first_seen = anyio.Event()
315+
token_seen = anyio.Event()
316+
release = anyio.Event()
317+
store = SequencedEventStore()
318+
319+
mcp = MCPServer("resumable")
320+
321+
@mcp.tool()
322+
async def hold(ctx: Context) -> str:
323+
"""Emit one notification, wait for the test, emit another, return."""
324+
await ctx.info("first")
325+
await release.wait()
326+
await ctx.info("second")
327+
return "done"
328+
329+
async def on_token(token: str) -> None:
330+
captured.append(token)
331+
if len(captured) >= 2:
332+
token_seen.set()
333+
334+
async def collect(params: LoggingMessageNotificationParams) -> None:
335+
received.append(params.data)
336+
first_seen.set()
337+
338+
call = CallToolRequest(params=CallToolRequestParams(name="hold", arguments={}))
339+
capture = ClientMessageMetadata(on_resumption_token_update=on_token)
340+
341+
async with mounted_app(mcp, event_store=store, retry_interval=0) as (http, manager):
342+
with anyio.fail_after(5): # pragma: no branch
343+
async with (
344+
streamable_http_client(f"{BASE_URL}/mcp", http_client=http, terminate_on_close=False) as (r1, w1),
345+
ClientSession(r1, w1, logging_callback=collect) as first,
346+
anyio.create_task_group() as tg,
347+
):
348+
await first.initialize()
349+
tg.start_soon(first.send_request, call, CallToolResult, None, capture)
350+
await first_seen.wait()
351+
await token_seen.wait()
352+
tg.cancel_scope.cancel()
353+
assert captured == snapshot(["3", "4"])
354+
assert received == snapshot(["first"])
355+
# The session id is only observable via the manager (the client transport does not expose it).
356+
(session_id,) = manager._server_instances
357+
358+
release.set()
359+
# init priming + init response + call priming + "first" + "second" + result = 6 stored events.
360+
await store.wait_until_stored(6)
361+
http.headers["mcp-session-id"] = session_id
362+
http.headers["mcp-protocol-version"] = LATEST_PROTOCOL_VERSION
363+
async with (
364+
streamable_http_client(f"{BASE_URL}/mcp", http_client=http) as (r2, w2),
365+
ClientSession(r2, w2, logging_callback=collect) as second,
366+
):
367+
result = await second.send_request(
368+
call, CallToolResult, metadata=ClientMessageMetadata(resumption_token=captured[-1])
369+
)
370+
assert result == snapshot(CallToolResult(content=[TextContent(text="done")], structured_content={"result": "done"}))
371+
assert received == snapshot(["first", "second"])

tests/interaction/transports/test_streamable_http.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,6 @@ async def announce(ctx: Context) -> str:
6363

6464

6565
@requirement("transport:streamable-http:json-response")
66-
@requirement("hosting:http:json-response-mode")
6766
@requirement("client-transport:http:json-response-parsed")
6867
async def test_tool_call_over_streamable_http_with_json_responses() -> None:
6968
"""The round trip works when the server answers with a single JSON body instead of an SSE stream."""

0 commit comments

Comments
 (0)