Add a client-side response cache honoring SEP-2549 caching hints (#3023)
This commit is contained in:
parent
67d7593df1
commit
b15b1d5f07
8 changed files with 3789 additions and 88 deletions
1087
tests/client/test_caching.py
Normal file
1087
tests/client/test_caching.py
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -506,6 +506,100 @@ async def test_modern_list_tools_drops_tools_with_invalid_x_mcp_header_but_legac
|
|||
assert [t.name for t in result.tools] == ["ok", "dropme"]
|
||||
|
||||
|
||||
_RETIRED_TOOL = Tool(
|
||||
name="retired",
|
||||
input_schema={"type": "object", "properties": {"region": {"type": "string", "x-mcp-header": "Region"}}},
|
||||
output_schema={"type": "object"},
|
||||
)
|
||||
_SURVIVOR_TOOL = Tool(name="survivor", input_schema={"type": "object"})
|
||||
|
||||
|
||||
def _scripted_listing_server(listings: list[ListToolsResult]) -> Server:
|
||||
"""Serves the given listings in order, one per tools/list request."""
|
||||
|
||||
async def on_list_tools(ctx: ServerRequestContext, params: types.PaginatedRequestParams | None) -> ListToolsResult:
|
||||
return listings.pop(0)
|
||||
|
||||
return Server("test", on_list_tools=on_list_tools)
|
||||
|
||||
|
||||
async def test_a_complete_listing_prunes_per_tool_state_for_tools_it_no_longer_contains() -> None:
|
||||
"""SDK-defined: a complete (uncursored, cursorless) listing is the full tool universe, so the
|
||||
header map and output schema derived from an earlier listing of a now-absent tool are dropped."""
|
||||
server = _scripted_listing_server(
|
||||
[
|
||||
ListToolsResult(tools=[_RETIRED_TOOL, _SURVIVOR_TOOL]),
|
||||
ListToolsResult(tools=[_SURVIVOR_TOOL]),
|
||||
]
|
||||
)
|
||||
|
||||
with anyio.fail_after(5):
|
||||
async with Client(server) as client:
|
||||
await client.session.list_tools()
|
||||
assert set(client.session._x_mcp_header_maps) == {"retired", "survivor"}
|
||||
assert set(client.session._tool_output_schemas) == {"retired", "survivor"}
|
||||
|
||||
await client.session.list_tools()
|
||||
assert set(client.session._x_mcp_header_maps) == {"survivor"}
|
||||
assert set(client.session._tool_output_schemas) == {"survivor"}
|
||||
|
||||
|
||||
async def test_a_complete_listing_prunes_output_schemas_on_a_legacy_session_too() -> None:
|
||||
"""SDK-defined: the prune is era-independent -- legacy sessions cache output schemas the same
|
||||
way (their header-map dict just stays empty, since the x-mcp-header filter is 2026-only)."""
|
||||
server = _scripted_listing_server(
|
||||
[
|
||||
ListToolsResult(tools=[_RETIRED_TOOL, _SURVIVOR_TOOL]),
|
||||
ListToolsResult(tools=[_SURVIVOR_TOOL]),
|
||||
]
|
||||
)
|
||||
|
||||
with anyio.fail_after(5):
|
||||
async with Client(server, mode="legacy") as client:
|
||||
await client.session.list_tools()
|
||||
assert set(client.session._tool_output_schemas) == {"retired", "survivor"}
|
||||
assert client.session._x_mcp_header_maps == {}
|
||||
|
||||
await client.session.list_tools()
|
||||
assert set(client.session._tool_output_schemas) == {"survivor"}
|
||||
|
||||
|
||||
async def test_a_listing_with_a_next_cursor_prunes_no_per_tool_state() -> None:
|
||||
"""SDK-defined: a first page carrying next_cursor is not the full universe -- state for tools
|
||||
expected on later pages must survive it."""
|
||||
server = _scripted_listing_server(
|
||||
[
|
||||
ListToolsResult(tools=[_RETIRED_TOOL, _SURVIVOR_TOOL]),
|
||||
ListToolsResult(tools=[_SURVIVOR_TOOL], next_cursor="2"),
|
||||
]
|
||||
)
|
||||
|
||||
with anyio.fail_after(5):
|
||||
async with Client(server) as client:
|
||||
await client.session.list_tools()
|
||||
await client.session.list_tools()
|
||||
assert set(client.session._x_mcp_header_maps) == {"retired", "survivor"}
|
||||
assert set(client.session._tool_output_schemas) == {"retired", "survivor"}
|
||||
|
||||
|
||||
async def test_a_cursor_page_fetch_prunes_no_per_tool_state() -> None:
|
||||
"""SDK-defined: a continuation page is partial even when it ends the pagination (no
|
||||
next_cursor) -- only an uncursored single-page listing prunes."""
|
||||
server = _scripted_listing_server(
|
||||
[
|
||||
ListToolsResult(tools=[_RETIRED_TOOL, _SURVIVOR_TOOL]),
|
||||
ListToolsResult(tools=[_SURVIVOR_TOOL]),
|
||||
]
|
||||
)
|
||||
|
||||
with anyio.fail_after(5):
|
||||
async with Client(server) as client:
|
||||
await client.session.list_tools()
|
||||
await client.session.list_tools(params=types.PaginatedRequestParams(cursor="2"))
|
||||
assert set(client.session._x_mcp_header_maps) == {"retired", "survivor"}
|
||||
assert set(client.session._tool_output_schemas) == {"retired", "survivor"}
|
||||
|
||||
|
||||
def test_client_rejects_handshake_era_mode_at_construction() -> None:
|
||||
"""A handshake-era protocol-version string passed as `mode=` is rejected by
|
||||
`__post_init__` with a hint to use `mode='legacy'` — the version-pin path is
|
||||
|
|
|
|||
1579
tests/client/test_client_caching.py
Normal file
1579
tests/client/test_client_caching.py
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -1661,6 +1661,33 @@ async def test_discover_reraises_unsupported_version_with_malformed_error_data()
|
|||
assert [m for m, _ in dispatcher.calls] == ["server/discover"]
|
||||
|
||||
|
||||
# --- inbound ttlMs clamp ---
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_a_positive_inbound_ttl_reaches_the_result_unchanged() -> None:
|
||||
listing: dict[str, Any] = {"resultType": "complete", "tools": [], "ttlMs": 60_000, "cacheScope": "private"}
|
||||
dispatcher = _ScriptedDispatcher(_discover_result_dict(), listing)
|
||||
with anyio.fail_after(5):
|
||||
async with ClientSession(dispatcher=dispatcher) as session:
|
||||
await session.discover()
|
||||
result = await session.list_tools()
|
||||
assert result.ttl_ms == 60_000
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.parametrize("wire_ttl", [True, False])
|
||||
async def test_a_boolean_inbound_ttl_is_not_clamped_only_coerced_by_validation(wire_ttl: bool) -> None:
|
||||
"""SDK-defined: `bool` is an `int` subclass; the clamp skips it and pydantic's lax mode coerces it instead."""
|
||||
listing: dict[str, Any] = {"resultType": "complete", "tools": [], "ttlMs": wire_ttl, "cacheScope": "private"}
|
||||
dispatcher = _ScriptedDispatcher(_discover_result_dict(), listing)
|
||||
with anyio.fail_after(5):
|
||||
async with ClientSession(dispatcher=dispatcher) as session:
|
||||
await session.discover()
|
||||
result = await session.list_tools()
|
||||
assert result.ttl_ms == int(wire_ttl)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_session_call_tool_returns_input_required_result_when_opted_in() -> None:
|
||||
"""`ClientSession.call_tool(..., allow_input_required=True)` surfaces the
|
||||
|
|
|
|||
|
|
@ -1,13 +1,19 @@
|
|||
"""`docs/advanced/caching.md`: every claim the page makes, proved against the real SDK."""
|
||||
|
||||
from collections.abc import Mapping
|
||||
from typing import Any, cast
|
||||
|
||||
import anyio
|
||||
import pytest
|
||||
from inline_snapshot import snapshot
|
||||
from mcp_types import INTERNAL_ERROR, ListToolsResult, PaginatedRequestParams, Tool
|
||||
|
||||
from docs_src.caching import tutorial001, tutorial002, tutorial003
|
||||
from mcp import Client
|
||||
from mcp.server import CacheHint, MCPServer
|
||||
from mcp import Client, MCPError
|
||||
from mcp.client import CacheConfig
|
||||
from mcp.client.caching import InMemoryResponseCacheStore
|
||||
from mcp.server import CacheHint, MCPServer, Server, ServerRequestContext
|
||||
from mcp.server.caching import CacheableMethod
|
||||
|
||||
# See test_index.py for why this is a per-module mark and not a conftest hook.
|
||||
pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")]
|
||||
|
|
@ -42,7 +48,7 @@ async def test_a_non_cacheable_method_is_rejected_at_construction() -> None:
|
|||
with pytest.raises(ValueError) as exc:
|
||||
MCPServer("Weather", cache_hints=cast(Any, {"tools/call": CacheHint(ttl_ms=1_000)}))
|
||||
assert str(exc.value) == snapshot(
|
||||
"cache_hints keys must be cacheable methods (see CacheableMethod); got: tools/call"
|
||||
"cache_hints keys must be cacheable methods (see CacheableMethod); got: 'tools/call'"
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -55,16 +61,149 @@ async def test_the_handler_value_wins_over_the_map_per_field() -> None:
|
|||
assert tools.cache_scope == "public"
|
||||
|
||||
|
||||
async def test_the_client_program_on_the_page_reads_the_hints(capsys: pytest.CaptureFixture[str]) -> None:
|
||||
"""tutorial003: `main()` is the literal client program on the page - the hints
|
||||
arrive as parsed fields on the result."""
|
||||
async def test_the_client_program_on_the_page_makes_three_fetches_for_four_calls(
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
"""tutorial003: a cache hit, an expiry, and `cache_mode="refresh"` make four calls cost three fetches."""
|
||||
await tutorial003.main()
|
||||
assert capsys.readouterr().out == "1 tools, fresh for 60s, scope=public\n"
|
||||
assert capsys.readouterr().out == "4 calls, 3 fetches\n"
|
||||
|
||||
|
||||
def _counting_tools_server(*, ttl_ms: int | None = 60_000) -> tuple[Server[Any], list[str | None]]:
|
||||
"""Each tools/list fetch returns a distinct tool name, so a cache hit is
|
||||
payload-distinguishable from a refetch; `ttl_ms=None` sends no hints."""
|
||||
fetches: list[str | None] = []
|
||||
|
||||
async def list_tools(ctx: ServerRequestContext[Any], params: PaginatedRequestParams | None) -> ListToolsResult:
|
||||
fetches.append(params.cursor if params is not None else None)
|
||||
return ListToolsResult(tools=[Tool(name=f"t{len(fetches) - 1}", input_schema={"type": "object"})])
|
||||
|
||||
hints: Mapping[CacheableMethod, CacheHint] | None = None
|
||||
if ttl_ms is not None:
|
||||
hints = {"tools/list": CacheHint(ttl_ms=ttl_ms)}
|
||||
return Server("counting", on_list_tools=list_tools, cache_hints=hints), fetches
|
||||
|
||||
|
||||
async def test_caching_is_on_by_default_the_second_call_makes_no_fetch() -> None:
|
||||
server, fetches = _counting_tools_server()
|
||||
async with Client(server) as client:
|
||||
first = await client.list_tools()
|
||||
second = await client.list_tools()
|
||||
assert fetches == [None]
|
||||
assert second == first
|
||||
|
||||
|
||||
async def test_a_hintless_result_is_not_cached_by_default() -> None:
|
||||
"""`default_ttl_ms` defaults to 0, so a hintless server sees its usual call-for-call traffic."""
|
||||
server, fetches = _counting_tools_server(ttl_ms=None)
|
||||
async with Client(server) as client:
|
||||
await client.list_tools()
|
||||
await client.list_tools()
|
||||
assert fetches == [None, None]
|
||||
|
||||
|
||||
async def test_cache_false_makes_every_call_a_round_trip() -> None:
|
||||
server, fetches = _counting_tools_server()
|
||||
async with Client(server, cache=False) as client:
|
||||
await client.list_tools()
|
||||
await client.list_tools()
|
||||
assert fetches == [None, None]
|
||||
|
||||
|
||||
async def test_refresh_refetches_and_replaces_the_cached_entry() -> None:
|
||||
server, fetches = _counting_tools_server()
|
||||
async with Client(server) as client:
|
||||
await client.list_tools()
|
||||
refreshed = await client.list_tools(cache_mode="refresh")
|
||||
served = await client.list_tools()
|
||||
assert fetches == [None, None]
|
||||
assert [tool.name for tool in refreshed.tools] == ["t1"]
|
||||
assert served == refreshed
|
||||
|
||||
|
||||
async def test_bypass_fetches_without_reading_or_writing_the_cache() -> None:
|
||||
server, fetches = _counting_tools_server()
|
||||
async with Client(server) as client:
|
||||
first = await client.list_tools()
|
||||
bypassed = await client.list_tools(cache_mode="bypass")
|
||||
served = await client.list_tools()
|
||||
assert fetches == [None, None]
|
||||
assert [tool.name for tool in bypassed.tools] == ["t1"]
|
||||
assert served == first
|
||||
|
||||
|
||||
async def test_an_expired_entry_is_not_revived_when_the_refetch_fails() -> None:
|
||||
"""SDK ruling: no stale-if-error - the refetch failure propagates."""
|
||||
now = 1_000_000.0
|
||||
fetches: list[None] = []
|
||||
|
||||
async def list_tools(ctx: ServerRequestContext[Any], params: PaginatedRequestParams | None) -> ListToolsResult:
|
||||
fetches.append(None)
|
||||
if len(fetches) > 1:
|
||||
raise MCPError(code=INTERNAL_ERROR, message="backend down")
|
||||
return ListToolsResult(tools=[Tool(name="t0", input_schema={"type": "object"})])
|
||||
|
||||
server = Server("flaky", on_list_tools=list_tools, cache_hints={"tools/list": CacheHint(ttl_ms=60_000)})
|
||||
async with Client(server, cache=CacheConfig(clock=lambda: now)) as client:
|
||||
await client.list_tools()
|
||||
now += 60.0 # past the 60s TTL
|
||||
with pytest.raises(MCPError) as exc:
|
||||
await client.list_tools()
|
||||
assert exc.value.code == INTERNAL_ERROR
|
||||
assert len(fetches) == 2
|
||||
|
||||
|
||||
async def test_two_concurrent_identical_calls_are_two_fetches() -> None:
|
||||
"""SDK ruling: no coalescing. The handler barrier releases only once both
|
||||
calls are inside it, so the test passes only if the fetches were concurrent."""
|
||||
both_fetching = anyio.Event()
|
||||
fetches: list[None] = []
|
||||
|
||||
async def list_tools(ctx: ServerRequestContext[Any], params: PaginatedRequestParams | None) -> ListToolsResult:
|
||||
fetches.append(None)
|
||||
if len(fetches) == 2:
|
||||
both_fetching.set()
|
||||
with anyio.fail_after(5):
|
||||
await both_fetching.wait()
|
||||
return ListToolsResult(tools=[Tool(name="t", input_schema={"type": "object"})])
|
||||
|
||||
server = Server("concurrent", on_list_tools=list_tools, cache_hints={"tools/list": CacheHint(ttl_ms=60_000)})
|
||||
async with Client(server) as client:
|
||||
async with anyio.create_task_group() as tg:
|
||||
tg.start_soon(client.list_tools)
|
||||
tg.start_soon(client.list_tools)
|
||||
assert len(fetches) == 2
|
||||
|
||||
|
||||
async def test_a_session_tier_call_always_makes_the_round_trip() -> None:
|
||||
"""The cache lives on the `Client` verbs; `client.session` sits below it."""
|
||||
server, fetches = _counting_tools_server()
|
||||
async with Client(server) as client:
|
||||
await client.list_tools()
|
||||
await client.session.list_tools()
|
||||
assert fetches == [None, None]
|
||||
|
||||
|
||||
async def test_a_custom_store_requires_a_partition() -> None:
|
||||
with pytest.raises(ValueError) as exc:
|
||||
CacheConfig(store=InMemoryResponseCacheStore())
|
||||
assert str(exc.value) == snapshot("a custom store requires an explicit partition")
|
||||
|
||||
|
||||
async def test_a_custom_store_with_an_in_process_server_requires_target_id() -> None:
|
||||
server, _ = _counting_tools_server()
|
||||
with pytest.raises(ValueError) as exc:
|
||||
Client(server, cache=CacheConfig(store=InMemoryResponseCacheStore(), partition="user-1"))
|
||||
assert str(exc.value) == snapshot(
|
||||
"a custom cache store requires CacheConfig.target_id when the server is not a URL: in-process servers "
|
||||
"and Transport instances get a random per-client identity, so their entries in a shared store could "
|
||||
"never be served to another client"
|
||||
)
|
||||
|
||||
|
||||
async def test_the_wire_presence_check_the_page_recommends_works() -> None:
|
||||
"""The page's claim: `"ttl_ms" in result.model_fields_set` distinguishes a
|
||||
server that sent the field from one that said nothing (model defaults)."""
|
||||
async with Client(tutorial003.mcp) as client:
|
||||
async with Client(tutorial001.mcp) as client:
|
||||
tools = await client.list_tools()
|
||||
assert "ttl_ms" in tools.model_fields_set
|
||||
|
|
|
|||
|
|
@ -511,7 +511,8 @@ async def test_modern_client_stops_mirroring_after_a_re_list_drops_the_tool() ->
|
|||
bad_schema = {"type": "object", "properties": {"a": {"type": "string", "x-mcp-header": "bad name"}}}
|
||||
valid = Tool(name="run", input_schema=schema)
|
||||
invalid = Tool(name="run", input_schema=bad_schema)
|
||||
listings = iter([valid, invalid])
|
||||
# Three pages: the call after the drop re-lists once because the prune also cleared `run`'s schema entry.
|
||||
listings = iter([valid, invalid, invalid])
|
||||
|
||||
async def list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult:
|
||||
return ListToolsResult(tools=[next(listings)], ttl_ms=0, cache_scope="public")
|
||||
|
|
|
|||
|
|
@ -1,40 +1,27 @@
|
|||
"""`mcp.server.caching`: `CacheHint` validation, per-field fills, and the
|
||||
`cache_hints` constructor map reaching the wire on both server tiers."""
|
||||
|
||||
from types import UnionType
|
||||
from typing import Any, cast, get_args
|
||||
from typing import Any, cast
|
||||
|
||||
import pytest
|
||||
from inline_snapshot import snapshot
|
||||
from mcp_types import (
|
||||
CacheableResult,
|
||||
InputRequiredResult,
|
||||
ListResourcesResult,
|
||||
ListToolsResult,
|
||||
PaginatedRequestParams,
|
||||
ReadResourceRequestParams,
|
||||
Resource,
|
||||
Tool,
|
||||
methods,
|
||||
)
|
||||
|
||||
from mcp import Client
|
||||
from mcp.server import CacheHint, MCPServer, Server, ServerRequestContext
|
||||
from mcp.server.caching import CACHEABLE_METHODS, apply_cache_hint
|
||||
from mcp.server.caching import apply_cache_hint
|
||||
|
||||
pytestmark = pytest.mark.anyio
|
||||
|
||||
|
||||
def test_cacheable_methods_match_the_result_models() -> None:
|
||||
"""Spec-mandated set (SEP-2549): `CACHEABLE_METHODS` mirrors exactly the
|
||||
methods whose monolith result models mix in `CacheableResult` - if the
|
||||
schema gains or loses a cacheable result, this weld breaks."""
|
||||
derived: set[str] = set()
|
||||
for method, model in methods.MONOLITH_RESULTS.items():
|
||||
arms = get_args(model) if isinstance(model, UnionType) else (model,)
|
||||
if any(isinstance(arm, type) and issubclass(arm, CacheableResult) for arm in arms):
|
||||
derived.add(method)
|
||||
assert CACHEABLE_METHODS == derived
|
||||
|
||||
|
||||
def test_cache_hint_defaults_match_the_conservative_model_defaults() -> None:
|
||||
"""SDK-defined: an unconfigured hint fills the same values the result models
|
||||
already default to - immediately stale, not shared - so stamping it is
|
||||
|
|
@ -83,7 +70,7 @@ def test_a_non_cacheable_method_in_cache_hints_is_rejected_at_server_constructio
|
|||
with pytest.raises(ValueError) as exc:
|
||||
Server("srv", cache_hints=cast(Any, {"tools/call": CacheHint()}))
|
||||
assert str(exc.value) == snapshot(
|
||||
"cache_hints keys must be cacheable methods (see CacheableMethod); got: tools/call"
|
||||
"cache_hints keys must be cacheable methods (see CacheableMethod); got: 'tools/call'"
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -96,6 +83,72 @@ def test_a_non_cache_hint_value_is_rejected_at_server_construction() -> None:
|
|||
assert str(exc.value) == snapshot("cache_hints['tools/list'] must be a CacheHint, got dict")
|
||||
|
||||
|
||||
def test_a_non_string_cache_hints_key_is_rejected_with_the_unknown_key_error() -> None:
|
||||
"""A non-string key takes the same unknown-key ValueError as a typo, not a TypeError from message formatting."""
|
||||
with pytest.raises(ValueError) as exc:
|
||||
Server("srv", cache_hints=cast(Any, {42: CacheHint()}))
|
||||
assert str(exc.value) == snapshot("cache_hints keys must be cacheable methods (see CacheableMethod); got: 42")
|
||||
|
||||
|
||||
async def test_a_dict_returning_handler_takes_the_configured_hint() -> None:
|
||||
"""The stamp covers raw-dict results too - 2026-07-28 requires both fields on the wire."""
|
||||
hint = CacheHint(ttl_ms=60_000, scope="public")
|
||||
|
||||
async def list_tools(ctx: ServerRequestContext[Any], params: PaginatedRequestParams) -> dict[str, Any]:
|
||||
return {"tools": [], "resultType": "complete"}
|
||||
|
||||
server = Server("srv", cache_hints={"tools/list": hint})
|
||||
server.add_request_handler("tools/list", PaginatedRequestParams, list_tools)
|
||||
async with Client(server) as client:
|
||||
result = await client.list_tools()
|
||||
assert result.ttl_ms == hint.ttl_ms
|
||||
assert result.cache_scope == hint.scope
|
||||
|
||||
|
||||
async def test_a_dict_provided_ttl_wins_and_the_hint_fills_only_the_missing_scope() -> None:
|
||||
"""Dict path mirrors the model path's `model_fields_set` precedence: present wire keys win."""
|
||||
|
||||
async def list_tools(ctx: ServerRequestContext[Any], params: PaginatedRequestParams) -> dict[str, Any]:
|
||||
return {"tools": [], "resultType": "complete", "ttlMs": 25}
|
||||
|
||||
server = Server("srv", cache_hints={"tools/list": CacheHint(ttl_ms=60_000, scope="public")})
|
||||
server.add_request_handler("tools/list", PaginatedRequestParams, list_tools)
|
||||
async with Client(server) as client:
|
||||
result = await client.list_tools()
|
||||
assert result.ttl_ms == 25
|
||||
assert result.cache_scope == "public"
|
||||
|
||||
|
||||
async def test_a_dict_returning_handler_leaks_no_hint_fields_to_a_2025_session() -> None:
|
||||
"""The stamp runs version-independently; the 2025 serialize sieve strips the fields."""
|
||||
|
||||
async def list_tools(ctx: ServerRequestContext[Any], params: PaginatedRequestParams) -> dict[str, Any]:
|
||||
return {"tools": []}
|
||||
|
||||
server = Server("srv", cache_hints={"tools/list": CacheHint(ttl_ms=60_000, scope="public")})
|
||||
server.add_request_handler("tools/list", PaginatedRequestParams, list_tools)
|
||||
async with Client(server, mode="legacy") as client:
|
||||
result = await client.list_tools()
|
||||
assert "ttl_ms" not in result.model_fields_set
|
||||
assert "cache_scope" not in result.model_fields_set
|
||||
|
||||
|
||||
async def test_an_input_required_shaped_dict_is_never_stamped() -> None:
|
||||
"""Spec carve-out: interim `input_required` results carry no cache hints, even on a hinted method."""
|
||||
|
||||
async def read_resource(ctx: ServerRequestContext[Any], params: ReadResourceRequestParams) -> dict[str, Any]:
|
||||
return {"resultType": "input_required", "requestState": "s1"}
|
||||
|
||||
server = Server("srv", cache_hints={"resources/read": CacheHint(ttl_ms=60_000, scope="public")})
|
||||
server.add_request_handler("resources/read", ReadResourceRequestParams, read_resource)
|
||||
async with Client(server) as client:
|
||||
result = await client.session.read_resource("res://x", allow_input_required=True)
|
||||
assert isinstance(result, InputRequiredResult)
|
||||
assert result.model_dump(by_alias=True, exclude_none=True) == snapshot(
|
||||
{"resultType": "input_required", "requestState": "s1"}
|
||||
)
|
||||
|
||||
|
||||
async def test_server_cache_hints_reach_the_wire_for_a_bare_handler_result() -> None:
|
||||
"""SDK-defined: a lowlevel handler that never thinks about caching emits the
|
||||
server-wide hint configured at construction."""
|
||||
|
|
|
|||
|
|
@ -548,6 +548,11 @@ def test_built_in_maps_are_immutable():
|
|||
_assign_item(built_in)
|
||||
|
||||
|
||||
def test_cacheable_methods_mirror_the_cacheable_method_literal():
|
||||
"""SEP-2549 weld: the hand-written Literal and the set derived from `MONOLITH_RESULTS` must agree."""
|
||||
assert methods.CACHEABLE_METHODS == frozenset(get_args(methods.CacheableMethod))
|
||||
|
||||
|
||||
def test_minimal_request_bodies_parse_through_every_request_row():
|
||||
for (method, version), surface_type in methods.CLIENT_REQUESTS.items():
|
||||
parsed = methods.parse_client_request(method, version, REQUEST_PARAMS_FIXTURES[surface_type])
|
||||
|
|
|
|||
Loading…
Reference in a new issue