Skip to content

Commit d85f2b1

Browse files
committed
Cover the caching and discover-versioning families: 17 tests
SEP-2549 server-side caching: cache hints pass through unmodified on prompts, resource-template, and discover results (the discover hints were previously pinned nowhere); ttlMs zero means immediately stale; absent hints default per the 2025 rules on the legacy cells; the interim input_required frame carries no hints while the same exchange's complete result does. Two recorded divergences: cross-page cacheScope consistency is delegated to handler authors (the spec MUST binds the server), and a negative ttlMs raises a validation error where the spec says clients should ignore-as-zero - the divergence note records that emission-side strictness is correct and only the inbound parse should clamp. Discover and versioning: instructions and derived capabilities ride DiscoverResult (with vacuity guards against silent legacy fallback), auto mode probes before negotiating, era-cached results are reused identically, the -32022 supported-list retry, dual-era precedence, and the era method gate - a 2025 method on a 2026 connection is method-not-found before any handler lookup, proven by a registered handler that never runs. 27 entries minted (13 tested, 14 deferred with greppable re-open tokens), 3 flips. 910 -> 931 cells, all accounted; suite green three runs.
1 parent 4a38c8b commit d85f2b1

6 files changed

Lines changed: 1006 additions & 16 deletions

File tree

tests/interaction/_requirements.py

Lines changed: 442 additions & 12 deletions
Large diffs are not rendered by default.
Lines changed: 347 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,347 @@
1+
"""SEP-2549 caching hints: producer-side stamping and the client-facing TTL/scope semantics.
2+
3+
The fixture-driven tests pin what the public Client surfaces on the era matrix; one test records
4+
2026 JSON-RPC frames over the modern HTTP entry because absent-vs-default hint keys are invisible
5+
to typed models; and one plays a non-conformant server by hand over memory streams because the
6+
typed Server cannot author the malformed value under test. The client-side response-cache
7+
behaviours (fresh windows, invalidation, cache keys) are deliberately absent: the SDK has no
8+
response cache, and the manifest tracks each as a deferred `caching:*` entry that re-opens when
9+
one lands.
10+
"""
11+
12+
import anyio
13+
import mcp_types as types
14+
import pytest
15+
from mcp_types import (
16+
DiscoverResult,
17+
ElicitRequest,
18+
ElicitRequestFormParams,
19+
ElicitResult,
20+
Implementation,
21+
InputRequiredResult,
22+
JSONRPCRequest,
23+
JSONRPCResponse,
24+
ListPromptsResult,
25+
ListResourceTemplatesResult,
26+
ListToolsResult,
27+
Prompt,
28+
ReadResourceResult,
29+
ResourceTemplate,
30+
ServerCapabilities,
31+
TextResourceContents,
32+
Tool,
33+
)
34+
from mcp_types.version import LATEST_MODERN_VERSION
35+
from pydantic import ValidationError
36+
37+
from mcp.client import ClientRequestContext
38+
from mcp.client.client import Client
39+
from mcp.client.session import ClientSession
40+
from mcp.client.streamable_http import streamable_http_client
41+
from mcp.server import Server, ServerRequestContext
42+
from mcp.shared.memory import create_client_server_memory_streams
43+
from mcp.shared.message import SessionMessage
44+
from tests.interaction._connect import BASE_URL, Connect, mounted_app
45+
from tests.interaction._helpers import RecordingTransport
46+
from tests.interaction._requirements import requirement
47+
48+
pytestmark = pytest.mark.anyio
49+
50+
# Non-default on purpose (the defaults are 0/"private"): a result the server failed to stamp
51+
# would surface the defaults, so only non-default values prove the authored hints travelled.
52+
PROMPTS_TTL_MS = 60_000
53+
TEMPLATES_TTL_MS = 120_000
54+
55+
_NAME_SCHEMA: dict[str, object] = {
56+
"type": "object",
57+
"properties": {"name": {"type": "string"}},
58+
"required": ["name"],
59+
}
60+
61+
62+
@requirement("caching:hints:prompts-list")
63+
async def test_prompts_list_result_carries_the_handler_authored_ttl_and_scope_hints(connect: Connect) -> None:
64+
"""Handler-authored ttlMs/cacheScope on a prompts/list result reach the client unmodified, on
65+
a resultType complete result. Spec-mandated (draft server/utilities/caching, the six-operation
66+
MUST); the non-default values prove the hints travelled -- a result the server failed to stamp
67+
would surface the 0/private defaults. On the streamable-http cell the 2026 wire surface makes
68+
both hints required, so the client's own validation co-proves wire presence.
69+
"""
70+
71+
async def list_prompts(ctx: ServerRequestContext, params: types.PaginatedRequestParams | None) -> ListPromptsResult:
72+
assert params is not None # the client always sends params, even without a cursor
73+
return ListPromptsResult(prompts=[Prompt(name="greet")], ttl_ms=PROMPTS_TTL_MS, cache_scope="public")
74+
75+
server = Server("cached", on_list_prompts=list_prompts)
76+
77+
async with connect(server) as client:
78+
result = await client.list_prompts()
79+
80+
assert result.ttl_ms == PROMPTS_TTL_MS
81+
assert result.cache_scope == "public"
82+
assert result.result_type == "complete"
83+
assert result.prompts == [Prompt(name="greet")]
84+
85+
86+
@requirement("caching:hints:resources-templates-list")
87+
async def test_resource_templates_list_result_carries_the_handler_authored_ttl_and_scope_hints(
88+
connect: Connect,
89+
) -> None:
90+
"""Handler-authored ttlMs/cacheScope on a resources/templates/list result reach the client
91+
unmodified, on a resultType complete result. Spec-mandated (draft server/utilities/caching --
92+
the sixth operation of the six-operation MUST); the non-default values prove the hints
93+
travelled, and on the streamable-http cell the required 2026 wire aliases co-prove wire
94+
presence.
95+
"""
96+
97+
async def list_resource_templates(
98+
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
99+
) -> ListResourceTemplatesResult:
100+
assert params is not None # the client always sends params, even without a cursor
101+
return ListResourceTemplatesResult(
102+
resource_templates=[ResourceTemplate(name="file", uri_template="file:///{name}")],
103+
ttl_ms=TEMPLATES_TTL_MS,
104+
cache_scope="public",
105+
)
106+
107+
server = Server("cached", on_list_resource_templates=list_resource_templates)
108+
109+
async with connect(server) as client:
110+
result = await client.list_resource_templates()
111+
112+
assert result.ttl_ms == TEMPLATES_TTL_MS
113+
assert result.cache_scope == "public"
114+
assert result.result_type == "complete"
115+
assert result.resource_templates == [ResourceTemplate(name="file", uri_template="file:///{name}")]
116+
117+
118+
@requirement("caching:pagination:same-scope-all-pages")
119+
async def test_mismatched_per_page_cache_scopes_are_forwarded_unmodified_across_a_cursor_walk(
120+
connect: Connect,
121+
) -> None:
122+
"""A handler that authors cacheScope public on page 1 and private on page 2 of one cursor walk
123+
reaches the client unmodified on both pages: the SDK applies no cross-page cacheScope
124+
consistency, so the spec's same-scope-all-pages MUST rests entirely on the handler author.
125+
Pins the known gap recorded on the requirement (divergence); a future enforcing SDK fails this
126+
test -- re-pin to `page2.cache_scope == page1.cache_scope` and delete the Divergence.
127+
"""
128+
seen_cursors: list[str | None] = []
129+
130+
async def list_tools(ctx: ServerRequestContext, params: types.PaginatedRequestParams | None) -> ListToolsResult:
131+
assert params is not None
132+
seen_cursors.append(params.cursor)
133+
if params.cursor is None:
134+
return ListToolsResult(
135+
tools=[Tool(name="a", input_schema={"type": "object"})],
136+
next_cursor="page-2",
137+
cache_scope="public",
138+
)
139+
assert params.cursor == "page-2"
140+
# Deliberately mismatched with page 1's "public": the forwarded mismatch is the pinned gap.
141+
return ListToolsResult(tools=[Tool(name="b", input_schema={"type": "object"})], cache_scope="private")
142+
143+
server = Server("cached", on_list_tools=list_tools)
144+
145+
async with connect(server) as client:
146+
page1 = await client.list_tools()
147+
page2 = await client.list_tools(cursor=page1.next_cursor)
148+
149+
assert page1.cache_scope == "public"
150+
assert page2.cache_scope == "private"
151+
# One request's page sequence, not two independent walks.
152+
assert seen_cursors == [None, "page-2"]
153+
154+
155+
@requirement("caching:ttl:absent-defaults-zero")
156+
async def test_a_result_without_ttl_from_a_2025_server_surfaces_the_immediately_stale_defaults(
157+
connect: Connect,
158+
) -> None:
159+
"""A 2025-era exchange carries no ttlMs/cacheScope on the wire (the
160+
hosting:http:legacy-no-modern-vocabulary entry pins the absence over HTTP); the client
161+
surfaces ttl_ms 0 -- immediately stale -- and the SDK's safe cacheScope default private. The
162+
ttl half is the spec SHOULD for older servers; the private half is SDK-defined (the spec
163+
sentence covers only ttlMs).
164+
"""
165+
166+
async def list_tools(ctx: ServerRequestContext, params: types.PaginatedRequestParams | None) -> ListToolsResult:
167+
assert params is not None
168+
# Neither hint authored: the spec's "older server versions" scenario, not laziness --
169+
# authored hints would be dropped on the 2025 wire anyway.
170+
return ListToolsResult(tools=[Tool(name="t", input_schema={"type": "object"})])
171+
172+
server = Server("cached", on_list_tools=list_tools)
173+
174+
async with connect(server) as client:
175+
result = await client.list_tools()
176+
177+
assert result.ttl_ms == 0
178+
assert result.cache_scope == "private"
179+
assert [tool.name for tool in result.tools] == ["t"]
180+
181+
182+
@requirement("caching:ttl:zero-immediately-stale")
183+
async def test_ttl_zero_results_are_refetched_on_every_access(connect: Connect) -> None:
184+
"""Two consecutive list_tools calls against a ttlMs-0 server both reach the handler: nothing
185+
is served from a cache. Honest provenance: this passes by construction -- the client has no
186+
response cache at all, so the identical observable would occur for any ttl (the positive-ttl
187+
fresh window is the deferred not-implemented sibling). The pin is the regression bar for a
188+
future cache: one that wrongly served a ttlMs-0 entry fails it.
189+
"""
190+
fetches: list[int] = []
191+
192+
async def list_tools(ctx: ServerRequestContext, params: types.PaginatedRequestParams | None) -> ListToolsResult:
193+
assert params is not None
194+
fetches.append(1)
195+
# ttl_ms=0 authored explicitly: the value under test, not the default's accident.
196+
return ListToolsResult(tools=[Tool(name="t", input_schema={"type": "object"})], ttl_ms=0, cache_scope="public")
197+
198+
server = Server("cached", on_list_tools=list_tools)
199+
200+
async with connect(server) as client:
201+
first = await client.list_tools()
202+
second = await client.list_tools()
203+
204+
assert len(fetches) == 2
205+
# The stamped value really was the one under test on both fetches.
206+
assert first.ttl_ms == 0
207+
assert second.ttl_ms == 0
208+
209+
210+
# --- wire-level: the modern HTTP entry is the only 2026 framing seam ---
211+
212+
213+
@requirement("caching:input-required:no-hints")
214+
@requirement("mrtr:input-required-result:result-type-serialized")
215+
async def test_the_interim_input_required_frame_carries_no_caching_hints_while_the_complete_frame_does() -> None:
216+
"""On one resources/read MRTR exchange, the serialized interim frame's result holds exactly
217+
inputRequests plus resultType input_required -- no ttlMs, no cacheScope -- while the terminal
218+
complete frame of the same method carries both. Asserted at the client transport seam over the
219+
modern HTTP entry because typed models hide absent-vs-default (the monolith would default-fill
220+
the hints on read-back); the in-test contrast frame guards the absence assertion against
221+
vacuity. Spec-mandated (draft server/utilities/caching: interim results are not cacheable and
222+
carry no caching hints), and the same key-set pin proves the resultType discriminator is
223+
serialized explicitly (the stacked mrtr entry). The unobservable consumer half ('are not
224+
cacheable') is recorded on the entry, not here.
225+
"""
226+
227+
async def read_resource(
228+
ctx: ServerRequestContext, params: types.ReadResourceRequestParams
229+
) -> ReadResourceResult | InputRequiredResult:
230+
assert str(params.uri) == "res://profile"
231+
if params.input_responses is None:
232+
return InputRequiredResult(
233+
input_requests={
234+
"who": ElicitRequest(params=ElicitRequestFormParams(message="Who?", requested_schema=_NAME_SCHEMA))
235+
}
236+
)
237+
answer = params.input_responses["who"]
238+
assert isinstance(answer, ElicitResult)
239+
assert answer.content is not None
240+
# Both hints authored non-default (the defaults are 0/"private"): the contrast frame is
241+
# provably handler-driven, not default fill.
242+
return ReadResourceResult(
243+
contents=[TextResourceContents(uri="res://profile", text=f"hi {answer.content['name']}")],
244+
ttl_ms=60_000,
245+
cache_scope="public",
246+
)
247+
248+
server = Server("cached", on_read_resource=read_resource)
249+
250+
async def answer_who(context: ClientRequestContext, params: types.ElicitRequestParams) -> ElicitResult:
251+
assert isinstance(params, ElicitRequestFormParams)
252+
return ElicitResult(action="accept", content={"name": "ada"})
253+
254+
with anyio.fail_after(5):
255+
# One combined async-with, the recorder bound via := -- a separately nested `async with`
256+
# line mis-traces its exit arcs under branch coverage on 3.11+.
257+
async with (
258+
mounted_app(server) as (http, _),
259+
Client(
260+
recording := RecordingTransport(streamable_http_client(f"{BASE_URL}/mcp", http_client=http)),
261+
mode=LATEST_MODERN_VERSION,
262+
elicitation_callback=answer_who,
263+
) as client,
264+
):
265+
result = await client.read_resource("res://profile")
266+
267+
# The whole sent log, not a filter: resources/read generates no implicit sibling traffic.
268+
reads = [message.message for message in recording.sent if isinstance(message.message, JSONRPCRequest)]
269+
assert [read.method for read in reads] == ["resources/read", "resources/read"]
270+
responses = {
271+
message.message.id: message.message.result
272+
for message in recording.received
273+
if isinstance(message, SessionMessage) and isinstance(message.message, JSONRPCResponse)
274+
}
275+
interim = responses[reads[0].id]
276+
complete = responses[reads[1].id]
277+
# Exact key vocabulary: a stronger absence claim than two `not in` checks -- any field added to
278+
# interim frames fails loudly -- and the explicit resultType value is the stacked entry's pin.
279+
assert sorted(interim) == ["inputRequests", "resultType"]
280+
assert interim["resultType"] == "input_required"
281+
# The same-exchange contrast frame: the terminal complete result of the same method carries both.
282+
assert complete["ttlMs"] == 60_000
283+
assert complete["cacheScope"] == "public"
284+
assert complete["resultType"] == "complete"
285+
# The typed surface agrees with the terminal frame.
286+
assert result.contents == [TextResourceContents(uri="res://profile", text="hi ada")]
287+
assert result.ttl_ms == 60_000
288+
289+
290+
# --- scripted peer: a malformed inbound value the typed Server cannot author ---
291+
292+
293+
@requirement("caching:ttl:negative-treated-as-zero")
294+
async def test_a_negative_ttl_from_a_nonconformant_server_is_rejected_not_coerced_to_zero() -> None:
295+
"""A tools/list answer carrying ttlMs -1 raises a pydantic ValidationError out of the awaiting
296+
call instead of being ignored and treated as 0 -- the spec SHOULD is not implemented (known
297+
gap recorded on the requirement: Field(ge=0) rejects before any leniency could run). The test
298+
plays the server by hand over memory streams because the typed Server cannot author a negative
299+
ttlMs (the same ge=0 constraint, at construction), and uses the bare pinned-2026 ClientSession
300+
because Client has no public connect path over raw scripted streams. When coerce-to-zero
301+
leniency lands, this test fails: re-pin to ttl_ms == 0 and delete the Divergence.
302+
"""
303+
async with create_client_server_memory_streams() as (client_streams, server_streams):
304+
client_read, client_write = client_streams
305+
server_read, server_write = server_streams
306+
307+
async def scripted_server() -> None:
308+
with anyio.fail_after(5):
309+
incoming = await server_read.receive()
310+
assert isinstance(incoming, SessionMessage)
311+
assert isinstance(incoming.message, JSONRPCRequest)
312+
assert incoming.message.method == "tools/list"
313+
await server_write.send(
314+
SessionMessage(
315+
JSONRPCResponse(
316+
jsonrpc="2.0",
317+
id=incoming.message.id,
318+
result={"tools": [], "resultType": "complete", "ttlMs": -1, "cacheScope": "public"},
319+
)
320+
)
321+
)
322+
# Returns naturally: the task group needs no cancel after the session context exits.
323+
324+
# One combined async-with: a separately nested `async with` line mis-traces its exit
325+
# arcs under branch coverage on 3.11+.
326+
async with (
327+
anyio.create_task_group() as task_group,
328+
ClientSession(client_read, client_write, client_info=Implementation(name="cli", version="0")) as session,
329+
):
330+
task_group.start_soon(scripted_server)
331+
session.adopt(
332+
DiscoverResult(
333+
supported_versions=[LATEST_MODERN_VERSION],
334+
capabilities=ServerCapabilities(),
335+
server_info=Implementation(name="srv", version="0"),
336+
)
337+
)
338+
with pytest.raises(ValidationError) as excinfo:
339+
with anyio.fail_after(5):
340+
await session.list_tools()
341+
342+
errors = excinfo.value.errors()
343+
assert len(errors) == 1
344+
assert errors[0]["loc"] == ("ttlMs",)
345+
# Stable pydantic-core identifier; the message text is third-party and
346+
# deliberately unpinned.
347+
assert errors[0]["type"] == "greater_than_equal"

0 commit comments

Comments
 (0)