|
| 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