Skip to content

Commit 4a7d563

Browse files
committed
test: cover composed flow scenarios and stdio framing requirements
1 parent 1e0d4f6 commit 4a7d563

9 files changed

Lines changed: 524 additions & 38 deletions

File tree

src/mcp/client/streamable_http.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -267,8 +267,8 @@ async def _handle_post_request(self, ctx: RequestContext) -> None:
267267
logger.debug("Received 202 Accepted")
268268
return
269269

270-
if response.status_code == 404: # pragma: no branch
271-
if isinstance(message, JSONRPCRequest): # pragma: no branch
270+
if response.status_code == 404:
271+
if isinstance(message, JSONRPCRequest):
272272
error_data = ErrorData(code=INVALID_REQUEST, message="Session terminated")
273273
session_message = SessionMessage(JSONRPCError(jsonrpc="2.0", id=message.id, error=error_data))
274274
await ctx.read_stream_writer.send(session_message)

tests/interaction/_requirements.py

Lines changed: 36 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -672,10 +672,9 @@ def __post_init__(self) -> None:
672672
"mcpserver:tool:extra": Requirement(
673673
source="sdk",
674674
behavior=(
675-
"Tool functions can access request metadata (request id, client params, session, lifespan "
676-
"state) through the Context parameter."
675+
"Tool functions can access request metadata (request id, client params, session) through the "
676+
"Context parameter."
677677
),
678-
deferred="Not yet covered here: planned gap test (Context request-metadata access from inside a tool).",
679678
),
680679
"mcpserver:tool:handler-throws": Requirement(
681680
source="sdk",
@@ -719,10 +718,6 @@ def __post_init__(self) -> None:
719718
"Tool input schemas generated from complex parameter types (unions, nested models, "
720719
"constrained types) validate and coerce arguments before the function runs."
721720
),
722-
deferred=(
723-
"Not yet covered here: planned gap test (complex parameter types validated and coerced before "
724-
"the function runs)."
725-
),
726721
),
727722
"mcpserver:tool:unknown-name": Requirement(
728723
source=f"{SPEC_BASE_URL}/server/tools#error-handling",
@@ -2186,10 +2181,6 @@ def __post_init__(self) -> None:
21862181
"session; the spec's MUST is not satisfied."
21872182
),
21882183
),
2189-
deferred=(
2190-
"Not implemented in the SDK: the client surfaces the 404 as an error to the caller instead of "
2191-
"re-initializing a new session."
2192-
),
21932184
),
21942185
"client-transport:http:accept-header-get": Requirement(
21952186
source=f"{SPEC_BASE_URL}/basic/transports#listening-for-messages-from-the-server",
@@ -2520,13 +2511,18 @@ def __post_init__(self) -> None:
25202511
"is not a valid MCP message is written to its stdin."
25212512
),
25222513
transports=("stdio",),
2523-
deferred="Not yet covered here: planned with the stdio end-to-end test.",
2514+
divergence=Divergence(
2515+
note=(
2516+
"stdio_server's own writes satisfy this, but it does not redirect or guard sys.stdout: "
2517+
"handler code that calls print() writes directly to the protocol stream and corrupts the "
2518+
"framing. The spec MUST is satisfied only as long as application code behaves."
2519+
),
2520+
),
25242521
),
25252522
"transport:stdio:no-embedded-newlines": Requirement(
25262523
source=f"{SPEC_BASE_URL}/basic/transports#stdio",
25272524
behavior="Serialized JSON-RPC messages on stdio contain no embedded newlines; one message per line.",
25282525
transports=("stdio",),
2529-
deferred="Not yet covered here: planned with the stdio end-to-end test.",
25302526
),
25312527
"transport:stdio:shutdown-escalation": Requirement(
25322528
source=f"{SPEC_BASE_URL}/basic/lifecycle#stdio",
@@ -2535,13 +2531,17 @@ def __post_init__(self) -> None:
25352531
"it (and kills it if still alive) after a grace period."
25362532
),
25372533
transports=("stdio",),
2538-
deferred="Not yet covered here; existing coverage in tests/client/test_stdio.py.",
2534+
deferred=(
2535+
"Not yet covered here: a server that ignores stdin close takes the full "
2536+
"PROCESS_TERMINATION_TIMEOUT (2.0 s) grace period plus up to a further 2.0 s for "
2537+
"SIGTERM/SIGKILL escalation; a robust test of that path is real-time-bound and the constant "
2538+
"is module-level (no public override). Covered by tests/client/test_stdio.py."
2539+
),
25392540
),
25402541
"transport:stdio:stderr-passthrough": Requirement(
25412542
source="sdk",
25422543
behavior="Server stderr is available to the client and is not consumed by the transport.",
25432544
transports=("stdio",),
2544-
deferred="Not yet covered here; existing coverage in tests/client/test_stdio.py.",
25452545
),
25462546
# ═══════════════════════════════════════════════════════════════════════════
25472547
# Composite end-to-end flows
@@ -2553,7 +2553,6 @@ def __post_init__(self) -> None:
25532553
"concurrently; clients on either transport can call the same tools."
25542554
),
25552555
transports=("streamable-http", "sse"),
2556-
deferred="Not yet covered here: planned with the transport conformance work.",
25572556
),
25582557
"flow:compat:streamable-then-sse-fallback": Requirement(
25592558
source=f"{SPEC_BASE_URL}/basic/transports#backwards-compatibility",
@@ -2562,15 +2561,25 @@ def __post_init__(self) -> None:
25622561
"SSE client transport against the same server connects successfully."
25632562
),
25642563
transports=("streamable-http", "sse"),
2565-
deferred="Not yet covered here: planned with the transport conformance work.",
2564+
divergence=Divergence(
2565+
note=(
2566+
"The SDK provides no automatic streamable-HTTP-to-SSE client fallback; the spec's "
2567+
"client-side SHOULD is left to the application to compose from streamable_http_client "
2568+
"and sse_client. Both halves are independently proven by the matrix."
2569+
),
2570+
),
2571+
deferred=(
2572+
"A demonstration test would only re-prove what the matrix already covers (an SSE-only "
2573+
"server is reachable via sse_client; an unmounted route returns 404), with the application "
2574+
"doing the fallback in between rather than the SDK."
2575+
),
25662576
),
25672577
"flow:elicitation:multi-step-form": Requirement(
25682578
source="sdk",
25692579
behavior=(
25702580
"A single tool handler issues sequential elicitations; an accept on one step feeds the next, "
25712581
"and a decline or cancel at any step short-circuits to a final result."
25722582
),
2573-
deferred="Not yet covered here: planned gap test (multi-step elicitation flow).",
25742583
),
25752584
"flow:elicitation:url-at-session-init": Requirement(
25762585
source="sdk",
@@ -2579,15 +2588,20 @@ def __post_init__(self) -> None:
25792588
"session initialization, before any client request."
25802589
),
25812590
transports=("streamable-http",),
2582-
deferred="Not yet covered here: planned with the transport conformance work.",
2591+
deferred=(
2592+
"No public per-session post-initialization hook exists on either server flavour "
2593+
"(Server.lifespan runs at server startup, not per session; ServerSession handles the "
2594+
"initialized notification internally with no callback). Driving 'before any client "
2595+
"request' deterministically would also require knowing the standalone GET stream is "
2596+
"established, which has no synchronization signal."
2597+
),
25832598
),
25842599
"flow:elicitation:url-required-then-retry": Requirement(
25852600
source=f"{SPEC_BASE_URL}/client/elicitation#url-elicitation-required-error",
25862601
behavior=(
25872602
"A tool call rejected with the URL-elicitation-required error can be retried successfully "
25882603
"after the client completes the URL flow and the server announces completion."
25892604
),
2590-
deferred="Not yet covered here: planned gap test (full URL-elicitation-required retry flow).",
25912605
),
25922606
"flow:multi-client:stateful-isolation": Requirement(
25932607
source="sdk",
@@ -2596,7 +2610,6 @@ def __post_init__(self) -> None:
25962610
"only the notifications produced by their own requests."
25972611
),
25982612
transports=("streamable-http",),
2599-
deferred="Not yet covered here: planned with the transport conformance work.",
26002613
),
26012614
"flow:oauth:authorization-code-roundtrip": Requirement(
26022615
source=f"{SPEC_BASE_URL}/basic/authorization#authorization-flow-steps",
@@ -2610,25 +2623,22 @@ def __post_init__(self) -> None:
26102623
"flow:resume:tool-call-resumption-token": Requirement(
26112624
source=f"{SPEC_BASE_URL}/basic/transports#resumability-and-redelivery",
26122625
behavior=(
2613-
"A tool call interrupted mid-stream can be resumed with the captured resumption token, "
2614-
"delivering only the remaining notifications and the final result."
2626+
"A tool call interrupted mid-stream is transparently resumed by the client transport using "
2627+
"the last-seen event id, delivering only the remaining notifications and the final result."
26152628
),
26162629
transports=("streamable-http",),
2617-
deferred="Not yet covered here; existing coverage in tests/shared/test_streamable_http.py.",
26182630
),
26192631
"flow:session:terminate-then-reconnect": Requirement(
26202632
source=f"{SPEC_BASE_URL}/basic/transports#session-management",
26212633
behavior=("After terminating a session, a fresh connection obtains a new session id and operations succeed."),
26222634
transports=("streamable-http",),
2623-
deferred="Not yet covered here: planned with the transport conformance work.",
26242635
),
26252636
"flow:tool-result:resource-link-follow": Requirement(
26262637
source=f"{SPEC_BASE_URL}/server/tools#resource-links",
26272638
behavior=(
26282639
"A resource_link returned by a tool call can be followed with resources/read on the linked "
26292640
"URI to retrieve the referenced contents."
26302641
),
2631-
deferred="Not yet covered here: planned gap test (follow a resource link returned by a tool).",
26322642
),
26332643
}
26342644

Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
"""Composed multi-feature flows against the low-level Server, driven through the public Client API.
2+
3+
Each test reads as the scenario it proves: the steps run top to bottom in the order a real client
4+
would perform them, composing two or more feature areas (a tool call followed by a resource read;
5+
a chain of elicitations inside one tool call; the full URL-elicitation-required retry loop). The
6+
individual features are pinned by their own tests; these prove they compose.
7+
"""
8+
9+
from collections.abc import Awaitable, Callable
10+
11+
import anyio
12+
import pytest
13+
from inline_snapshot import snapshot
14+
15+
from mcp import MCPError, UrlElicitationRequiredError, types
16+
from mcp.client import ClientRequestContext
17+
from mcp.server import Server, ServerRequestContext
18+
from mcp.server.session import ServerSession
19+
from mcp.types import (
20+
URL_ELICITATION_REQUIRED,
21+
CallToolResult,
22+
ElicitCompleteNotification,
23+
ElicitRequestFormParams,
24+
ElicitRequestURLParams,
25+
ElicitResult,
26+
ListToolsResult,
27+
ReadResourceResult,
28+
ResourceLink,
29+
TextContent,
30+
TextResourceContents,
31+
Tool,
32+
)
33+
from tests.interaction._connect import Connect
34+
from tests.interaction._helpers import IncomingMessage
35+
from tests.interaction._requirements import requirement
36+
37+
pytestmark = pytest.mark.anyio
38+
39+
ListToolsHandler = Callable[
40+
[ServerRequestContext, types.PaginatedRequestParams | None], Awaitable[types.ListToolsResult]
41+
]
42+
43+
44+
def _list_tools(*names: str) -> ListToolsHandler:
45+
"""A list_tools handler advertising the named tools, so call_tool's implicit list succeeds."""
46+
47+
async def list_tools(ctx: ServerRequestContext, params: types.PaginatedRequestParams | None) -> ListToolsResult:
48+
return ListToolsResult(tools=[Tool(name=name, input_schema={"type": "object"}) for name in names])
49+
50+
return list_tools
51+
52+
53+
@requirement("flow:tool-result:resource-link-follow")
54+
async def test_a_resource_link_returned_by_a_tool_can_be_followed_with_read(connect: Connect) -> None:
55+
"""A tool returns a resource_link; reading that link's URI returns the referenced contents.
56+
57+
Steps: (1) call the tool, (2) extract the link from its content, (3) read_resource on the
58+
link's URI, (4) the read result carries the linked contents.
59+
"""
60+
61+
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
62+
assert params.name == "generate"
63+
return CallToolResult(content=[ResourceLink(uri="file:///report.txt", name="report")])
64+
65+
async def read_resource(ctx: ServerRequestContext, params: types.ReadResourceRequestParams) -> ReadResourceResult:
66+
assert str(params.uri) == "file:///report.txt"
67+
return ReadResourceResult(contents=[TextResourceContents(uri="file:///report.txt", text="generated")])
68+
69+
server = Server(
70+
"linker", on_list_tools=_list_tools("generate"), on_call_tool=call_tool, on_read_resource=read_resource
71+
)
72+
73+
async with connect(server) as client:
74+
called = await client.call_tool("generate", {})
75+
link = called.content[0]
76+
assert isinstance(link, ResourceLink)
77+
read = await client.read_resource(link.uri)
78+
79+
assert called == snapshot(CallToolResult(content=[ResourceLink(name="report", uri="file:///report.txt")]))
80+
assert read == snapshot(
81+
ReadResourceResult(contents=[TextResourceContents(uri="file:///report.txt", text="generated")])
82+
)
83+
84+
85+
@requirement("flow:elicitation:multi-step-form")
86+
async def test_a_tool_handler_chains_form_elicitations_feeding_each_answer_forward(connect: Connect) -> None:
87+
"""Sequential form elicitations inside one tool call: each accepted answer feeds the next step.
88+
89+
Steps: (1) call the tool, (2) the handler issues a step-one form elicitation that the client
90+
accepts with content, (3) the handler issues a step-two elicitation whose message references
91+
the step-one answer, (4) the client accepts step two, (5) the tool result summarises both
92+
answers. The callback is invoked exactly twice with the expected messages and schemas. The
93+
short-circuit on decline is the application's choice (proven separately by the per-action
94+
elicitation tests); what this flow pins is that the chain itself works end to end.
95+
"""
96+
received: list[ElicitRequestFormParams] = []
97+
answers: list[dict[str, str | int | float | bool | list[str] | None]] = [{"name": "ada"}, {"age": 37}]
98+
99+
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
100+
assert params.name == "onboard"
101+
first = await ctx.session.elicit_form(
102+
"Step 1: choose a username.", {"type": "object", "properties": {"name": {"type": "string"}}}
103+
)
104+
assert first.action == "accept" and first.content is not None
105+
second = await ctx.session.elicit_form(
106+
f"Step 2: confirm age for {first.content['name']}.",
107+
{"type": "object", "properties": {"age": {"type": "integer"}}},
108+
)
109+
assert second.action == "accept" and second.content is not None
110+
return CallToolResult(content=[TextContent(text=f"{first.content['name']} is {second.content['age']}")])
111+
112+
server = Server("onboarder", on_list_tools=_list_tools("onboard"), on_call_tool=call_tool)
113+
114+
async def answer(context: ClientRequestContext, params: types.ElicitRequestParams) -> ElicitResult:
115+
assert isinstance(params, ElicitRequestFormParams)
116+
received.append(params)
117+
return ElicitResult(action="accept", content=answers[len(received) - 1])
118+
119+
async with connect(server, elicitation_callback=answer) as client:
120+
result = await client.call_tool("onboard", {})
121+
122+
assert result == snapshot(CallToolResult(content=[TextContent(text="ada is 37")]))
123+
assert [(p.message, p.requested_schema) for p in received] == snapshot(
124+
[
125+
("Step 1: choose a username.", {"type": "object", "properties": {"name": {"type": "string"}}}),
126+
("Step 2: confirm age for ada.", {"type": "object", "properties": {"age": {"type": "integer"}}}),
127+
]
128+
)
129+
130+
131+
@requirement("flow:elicitation:url-required-then-retry")
132+
async def test_a_tool_rejected_with_url_elicitation_required_succeeds_on_retry_after_completion(
133+
connect: Connect,
134+
) -> None:
135+
"""The full URL-elicitation-required retry loop: -32042, completion announced, retry succeeds.
136+
137+
Steps: (1) the first call is rejected with -32042 carrying the required URL elicitation in
138+
its error data, (2) the client extracts the elicitation id from the error, (3) the server
139+
announces completion via the elicitation/complete notification (driven via the captured
140+
session, the same way a real out-of-band callback would reach a held session reference),
141+
(4) the client observes the matching completion notification and retries, (5) the retry
142+
succeeds. The handler distinguishes the two calls by a closure flag the test flips between
143+
them; the test waits on the completion notification with an event so the retry only happens
144+
after the announcement has arrived.
145+
"""
146+
elicitation_id = "auth-001"
147+
authorised: list[bool] = [False]
148+
captured: list[ServerSession] = []
149+
completed = anyio.Event()
150+
notifications: list[ElicitCompleteNotification] = []
151+
152+
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
153+
assert params.name == "read_files"
154+
captured.append(ctx.session)
155+
if not authorised[0]:
156+
# The log line gives the message handler a non-completion notification, so the test's
157+
# filtering branch is exercised in both directions and the wait remains specific.
158+
await ctx.session.send_log_message(level="warning", data="authorisation required", logger="gate")
159+
raise UrlElicitationRequiredError(
160+
[
161+
ElicitRequestURLParams(
162+
message="Authorize file access.",
163+
url="https://example.com/oauth/authorize",
164+
elicitation_id=elicitation_id,
165+
)
166+
]
167+
)
168+
return CallToolResult(content=[TextContent(text="contents")])
169+
170+
server = Server("gatekeeper", on_list_tools=_list_tools("read_files"), on_call_tool=call_tool)
171+
172+
async def collect(message: IncomingMessage) -> None:
173+
if isinstance(message, ElicitCompleteNotification):
174+
notifications.append(message)
175+
completed.set()
176+
177+
async with connect(server, message_handler=collect) as client:
178+
with pytest.raises(MCPError) as exc_info:
179+
await client.call_tool("read_files", {})
180+
assert exc_info.value.error.code == URL_ELICITATION_REQUIRED
181+
required = UrlElicitationRequiredError.from_error(exc_info.value.error)
182+
assert [e.elicitation_id for e in required.elicitations] == [elicitation_id]
183+
184+
# The out-of-band interaction completes; the server announces it on the same session.
185+
await captured[0].send_elicit_complete(elicitation_id)
186+
with anyio.fail_after(5):
187+
await completed.wait()
188+
assert notifications[0].params.elicitation_id == elicitation_id
189+
190+
authorised[0] = True
191+
result = await client.call_tool("read_files", {})
192+
193+
assert result == snapshot(CallToolResult(content=[TextContent(text="contents")]))

0 commit comments

Comments
 (0)