Files
modelcontextprotocol--pytho…/tests/client/test_client_caching.py
T
wehub-resource-sync 49b9bb6724
Deploy Docs / deploy-docs (push) Failing after 1s
Conformance Tests / client-conformance (push) Failing after 3s
Conformance Tests / server-conformance (push) Failing after 1s
GitHub Actions Security Analysis / zizmor (push) Failing after 1s
CI / checks (push) Failing after 59m20s
CI / all-green (push) Waiting to run
chore: import upstream snapshot with attribution
2026-07-13 12:10:27 +08:00

1580 lines
66 KiB
Python

"""`Client` wiring for the response cache: the `cache=` kwarg, server identity
resolution, the custom-store guard, notification eviction, and the five cacheable
verbs. The coordinator's own behavior is covered in `test_caching.py`."""
import hashlib
import json
import time
from collections.abc import AsyncIterator, Awaitable, Callable
from contextlib import asynccontextmanager
from types import TracebackType
from typing import Any, Literal
import anyio
import anyio.lowlevel
import httpx
import mcp_types as types
import pytest
from inline_snapshot import snapshot
from mcp_types import (
INTERNAL_ERROR,
INVALID_PARAMS,
CallToolResult,
DiscoverResult,
ElicitRequest,
ElicitRequestFormParams,
ElicitResult,
Implementation,
InputRequiredResult,
ListPromptsResult,
ListResourcesResult,
ListResourceTemplatesResult,
ListToolsResult,
ReadResourceResult,
ResourceListChangedNotification,
ResourceUpdatedNotification,
ResourceUpdatedNotificationParams,
ServerCapabilities,
ServerNotification,
TextContent,
TextResourceContents,
Tool,
ToolListChangedNotification,
)
from mcp_types.version import LATEST_MODERN_VERSION
from mcp.client import Client
from mcp.client._transport import TransportStreams
from mcp.client.caching import (
CacheConfig,
CacheEntry,
CacheKey,
ClientResponseCache,
InMemoryResponseCacheStore,
)
from mcp.client.streamable_http import streamable_http_client
from mcp.server import Server, ServerRequestContext
from mcp.server.caching import CacheHint
from mcp.shared.exceptions import MCPError
from mcp.shared.memory import MessageStream, create_client_server_memory_streams
from mcp.shared.message import SessionMessage
from mcp.shared.session import RequestResponder
from tests.interaction._connect import BASE_URL, mounted_app
pytestmark = pytest.mark.anyio
IncomingMessage = RequestResponder[types.ServerRequest, types.ClientResult] | types.ServerNotification | Exception
def _coordinator(client: Client) -> ClientResponseCache:
cache = client._response_cache
assert cache is not None
return cache
def _private_arm(client: Client) -> str:
"""The identity arm stamped into store keys; only equality between clients matters here."""
return _coordinator(client)._arm("private")
def _tools_list_key(client: Client) -> CacheKey:
return CacheKey("tools/list", "", _private_arm(client))
class _OpaqueTransport:
"""Shape-only `Transport`: identity resolution happens at construction, so tests never enter it."""
async def __aenter__(self) -> TransportStreams:
raise NotImplementedError
async def __aexit__(
self, exc_type: type[BaseException] | None, exc_value: BaseException | None, traceback: TracebackType | None
) -> None:
raise NotImplementedError
def _list_changed_server() -> Server[Any]:
"""Server whose `touch` tool emits tools/list_changed; connect with `mode="legacy"`
because the modern in-process path drops standalone server notifications."""
async def list_tools(ctx: ServerRequestContext, params: types.PaginatedRequestParams | None) -> ListToolsResult:
return ListToolsResult(tools=[types.Tool(name="touch", input_schema={"type": "object"})])
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
assert params.name == "touch"
await ctx.session.send_tool_list_changed()
return CallToolResult(content=[TextContent(text="touched")])
return Server("notifier", on_list_tools=list_tools, on_call_tool=call_tool)
async def _warm_tools_list_entry(client: Client) -> CacheKey:
"""Seed a private-arm tools/list entry directly in the store; payload and expiry are inert to eviction."""
key = _tools_list_key(client)
await _coordinator(client)._store.set(key, CacheEntry(value="warm", scope="private", expires_at=None))
return key
def test_an_explicit_target_id_overrides_both_url_and_in_process_identity() -> None:
by_target_url = Client("https://example.com/mcp", cache=CacheConfig(target_id="svc"))
by_target_inproc = Client(Server("plain"), cache=CacheConfig(target_id="svc"))
by_url = Client("https://example.com/mcp")
assert _private_arm(by_target_url) == _private_arm(by_target_inproc)
assert _private_arm(by_target_url) != _private_arm(by_url)
def test_userinfo_variants_of_a_server_url_share_one_cache_identity() -> None:
"""Stripping userinfo is the single permitted URL rewrite."""
bare = Client("https://example.com/mcp")
with_password = Client("https://user:secret@example.com/mcp")
with_token = Client("https://token@example.com/mcp")
assert _private_arm(bare) == _private_arm(with_password) == _private_arm(with_token)
@pytest.mark.parametrize(
("with_userinfo", "bare"),
[
("HTTPS://a@X.example/mcp", "HTTPS://X.example/mcp"),
("https://u@h/p?", "https://h/p?"),
("https://u@h/p#", "https://h/p#"),
("https://u\tser:p@h.example/p", "https://h.example/p"),
("https://u:p@h.example/pa\tth", "https://h.example/pa\tth"),
],
ids=["scheme-case", "empty-query", "empty-fragment", "tab-in-userinfo", "tab-in-path"],
)
def test_stripping_userinfo_changes_no_other_byte_of_the_url(with_userinfo: str, bare: str) -> None:
"""The removed `userinfo@` is the only byte difference: no scheme case-folding, no dropped
empty `?`/`#` delimiters, and control characters - which urlsplit would silently strip,
misaligning any parser-derived slice - stay byte-exact outside the removed span. A
userinfo-free URL passes through untouched, so arm equality proves the stripped form is
byte-identical to the bare URL."""
assert _private_arm(Client(with_userinfo)) == _private_arm(Client(bare))
def test_a_url_without_an_authority_passes_through_unchanged() -> None:
"""No `//` means no authority span, so an `@` elsewhere strips nothing."""
arm_id = hashlib.sha256(b"mailto:a@b").hexdigest()
assert _private_arm(Client("mailto:a@b")) == json.dumps(["private", None, arm_id, ""])
def test_the_server_url_is_sha256_hashed_before_it_enters_key_material() -> None:
"""Pins the docs' secrets-never-in-keys claim: a query-string secret never appears in store keys."""
client = Client("https://user:pass@example.com/mcp?api_key=SECRET")
arm_id = hashlib.sha256(b"https://example.com/mcp?api_key=SECRET").hexdigest()
# The era slot is None pre-connect; only the identity hash matters here.
assert _private_arm(client) == json.dumps(["private", None, arm_id, ""])
def test_urls_differing_only_in_query_have_distinct_cache_identities() -> None:
"""URL identity is byte-exact outside userinfo; over-normalization would merge tenants."""
tenant_a = Client("https://example.com/mcp?tenant=a")
tenant_b = Client("https://example.com/mcp?tenant=b")
assert _private_arm(tenant_a) != _private_arm(tenant_b)
def test_two_clients_on_one_in_process_server_get_distinct_cache_identities() -> None:
server = Server("plain")
assert _private_arm(Client(server)) != _private_arm(Client(server))
def test_a_transport_object_gets_a_per_client_cache_identity() -> None:
transport = _OpaqueTransport()
assert _private_arm(Client(transport)) != _private_arm(Client(transport))
@pytest.mark.parametrize("make_server", [lambda: Server("plain"), _OpaqueTransport], ids=["in-process", "transport"])
def test_a_custom_store_without_a_url_or_target_id_is_rejected(make_server: Any) -> None:
with pytest.raises(ValueError) as exc_info:
Client(make_server(), cache=CacheConfig(store=InMemoryResponseCacheStore(), partition="p"))
assert str(exc_info.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"
)
def test_a_custom_store_with_a_url_server_constructs_and_is_used() -> None:
store = InMemoryResponseCacheStore()
client = Client("https://example.com/mcp", cache=CacheConfig(store=store, partition="p"))
assert _coordinator(client)._store is store
def test_a_custom_store_with_an_explicit_target_id_constructs_for_any_server() -> None:
store = InMemoryResponseCacheStore()
client = Client(Server("plain"), cache=CacheConfig(store=store, partition="p", target_id="svc"))
assert _coordinator(client)._store is store
async def test_cache_false_disables_the_cache_and_the_handler_wrap() -> None:
async def handler(message: IncomingMessage) -> None:
raise NotImplementedError
client = Client(_list_changed_server(), cache=False, message_handler=handler)
assert client._response_cache is None
async with client:
assert client.session._message_handler is handler
def test_the_default_cache_uses_a_per_client_in_memory_store() -> None:
"""`cache=None` (the default) is cache-on."""
server = Server("plain")
first = Client(server)
second = Client(server)
assert isinstance(_coordinator(first)._store, InMemoryResponseCacheStore)
assert _coordinator(first)._store is not _coordinator(second)._store
async def test_the_negotiated_version_supplier_tracks_the_session_lifecycle() -> None:
"""The era gate must never read a stale or raising source."""
client = Client(_list_changed_server())
supplier = _coordinator(client)._negotiated_version
assert supplier() is None
async with client:
assert supplier() == client.protocol_version
assert supplier() is None
async def test_a_list_changed_notification_evicts_without_a_user_handler() -> None:
"""Spec SHOULD (notifications invalidate): the entry is deleted from both arms."""
class _EventedStore(InMemoryResponseCacheStore):
"""Signals once both arms of an eviction have been deleted."""
def __init__(self) -> None:
super().__init__()
self._deletes = 0
self.both_arms_deleted = anyio.Event()
async def delete(self, key: CacheKey) -> None:
await super().delete(key)
self._deletes += 1
if self._deletes == 2:
self.both_arms_deleted.set()
store = _EventedStore()
client = Client(
_list_changed_server(), mode="legacy", cache=CacheConfig(store=store, partition="p", target_id="svc")
)
async with client:
key = await _warm_tools_list_entry(client)
await client.call_tool("touch", {})
with anyio.fail_after(5):
await store.both_arms_deleted.wait()
assert await store.get(key) is None
async def test_a_user_handler_receives_the_notification_the_eviction_consumed() -> None:
"""Eviction is a tee, not a filter."""
received: list[IncomingMessage] = []
seen = anyio.Event()
async def collect(message: IncomingMessage) -> None:
received.append(message)
seen.set()
client = Client(_list_changed_server(), mode="legacy", message_handler=collect)
async with client:
key = await _warm_tools_list_entry(client)
await client.call_tool("touch", {})
with anyio.fail_after(5):
await seen.wait()
# The wrap evicts before delegating: delivery implies the entry is gone.
assert await _coordinator(client)._store.get(key) is None
assert received == snapshot([ToolListChangedNotification()])
async def test_non_notification_items_pass_through_to_the_user_handler_untouched() -> None:
"""Transport `Exception` items can't occur in-process, so the installed handler is invoked directly."""
received: list[IncomingMessage] = []
async def collect(message: IncomingMessage) -> None:
received.append(message)
client = Client(_list_changed_server(), message_handler=collect)
async with client:
installed = client.session._message_handler
assert installed is not collect # the wrap, not the bare user handler
key = await _warm_tools_list_entry(client)
fault = RuntimeError("stream broke")
await installed(fault)
assert received == [fault]
assert await _coordinator(client)._store.get(key) is not None
async def test_a_raising_eviction_does_not_block_notification_delivery(caplog: pytest.LogCaptureFixture) -> None:
class _ExplodingCache(ClientResponseCache):
async def evict_for_notification(self, notification: ServerNotification) -> None:
raise RuntimeError("cache bug")
received: list[IncomingMessage] = []
seen = anyio.Event()
async def collect(message: IncomingMessage) -> None:
received.append(message)
seen.set()
client = Client(_list_changed_server(), mode="legacy", message_handler=collect)
# The wrap reads `_response_cache` at session build, so the swap must happen pre-enter.
client._response_cache = _ExplodingCache(
store=InMemoryResponseCacheStore(),
partition="",
arm_id="arm",
default_ttl_ms=0,
clock=time.time,
share_public=False,
negotiated_version=lambda: None,
)
async with client:
await client.call_tool("touch", {})
with anyio.fail_after(5):
await seen.wait()
assert received == snapshot([ToolListChangedNotification()])
assert "Response cache eviction failed; the notification is still delivered" in [
record.message for record in caplog.records
]
# --- The cacheable verbs ---
class _ManualClock:
"""Injected wall clock: tests advance `now` instead of sleeping."""
def __init__(self) -> None:
self.now = 1_000_000.0
def __call__(self) -> float:
return self.now
def _varying_tools_server(
*, ttl_ms: int = 60_000, scope: Literal["public", "private"] = "private"
) -> tuple[Server[Any], list[str | None]]:
"""Server whose every tools/list fetch returns a distinct tool name `t<n>`,
so a served entry is distinguishable from a refetch by payload."""
fetches: list[str | None] = []
async def list_tools(ctx: ServerRequestContext, params: types.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"})])
server = Server(
"varying", on_list_tools=list_tools, cache_hints={"tools/list": CacheHint(ttl_ms=ttl_ms, scope=scope)}
)
return server, fetches
def _tool_names(result: ListToolsResult) -> list[str]:
return [tool.name for tool in result.tools]
async def test_a_second_list_tools_within_the_ttl_is_served_from_the_cache() -> None:
"""SEP-2549: a result carrying a `ttlMs` hint is reusable until it expires."""
server, fetches = _varying_tools_server()
async with Client(server, cache=CacheConfig(clock=_ManualClock())) as client:
first = await client.list_tools()
second = await client.list_tools()
assert fetches == [None]
assert second == first
async def test_an_expired_entry_is_refetched() -> None:
"""Freshness is strict: at exactly `ttlMs` the entry is expired."""
clock = _ManualClock()
server, fetches = _varying_tools_server(ttl_ms=60_000)
async with Client(server, cache=CacheConfig(clock=clock)) as client:
assert _tool_names(await client.list_tools()) == ["t0"]
clock.now += 60.0
assert _tool_names(await client.list_tools()) == ["t1"]
assert fetches == [None, None]
async def test_each_list_verb_caches_independently_under_its_own_method() -> None:
"""Cache keys discriminate by method (spec MUST)."""
fetched: list[str] = []
async def list_tools(ctx: ServerRequestContext, params: types.PaginatedRequestParams | None) -> ListToolsResult:
fetched.append("tools/list")
return ListToolsResult(tools=[])
async def list_prompts(ctx: ServerRequestContext, params: types.PaginatedRequestParams | None) -> ListPromptsResult:
fetched.append("prompts/list")
return ListPromptsResult(prompts=[])
async def list_resources(
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
) -> ListResourcesResult:
fetched.append("resources/list")
return ListResourcesResult(resources=[])
async def list_templates(
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
) -> ListResourceTemplatesResult:
fetched.append("resources/templates/list")
return ListResourceTemplatesResult(resource_templates=[])
hint = CacheHint(ttl_ms=60_000)
server = Server(
"all-lists",
on_list_tools=list_tools,
on_list_prompts=list_prompts,
on_list_resources=list_resources,
on_list_resource_templates=list_templates,
cache_hints={
"tools/list": hint,
"prompts/list": hint,
"resources/list": hint,
"resources/templates/list": hint,
},
)
async with Client(server, cache=CacheConfig(clock=_ManualClock())) as client:
await client.list_tools()
await client.list_prompts()
await client.list_resources()
await client.list_resource_templates()
await client.list_tools()
await client.list_prompts()
await client.list_resources()
await client.list_resource_templates()
assert fetched == ["tools/list", "prompts/list", "resources/list", "resources/templates/list"]
async def test_read_resource_caches_per_uri() -> None:
"""Cache keys discriminate by result-affecting params (spec MUST)."""
reads: list[str] = []
async def read(ctx: ServerRequestContext, params: types.ReadResourceRequestParams) -> ReadResourceResult:
reads.append(params.uri)
return ReadResourceResult(contents=[TextResourceContents(uri=params.uri, text=params.uri)])
server = Server("res", on_read_resource=read, cache_hints={"resources/read": CacheHint(ttl_ms=60_000)})
async with Client(server, cache=CacheConfig(clock=_ManualClock())) as client:
first_a = await client.read_resource("memo://a")
first_b = await client.read_resource("memo://b")
assert await client.read_resource("memo://a") == first_a
assert await client.read_resource("memo://b") == first_b
assert reads == ["memo://a", "memo://b"]
def _paginated_tools_server() -> tuple[Server[Any], list[str | None]]:
"""Cacheable first page; cursor "expired" -> INVALID_PARAMS (the spec's expired-cursor
signal), "fail" -> INTERNAL_ERROR."""
fetches: list[str | None] = []
async def list_tools(ctx: ServerRequestContext, params: types.PaginatedRequestParams | None) -> ListToolsResult:
cursor = params.cursor if params is not None else None
fetches.append(cursor)
if cursor is None:
first_page = Tool(name="first-page", input_schema={"type": "object"})
return ListToolsResult(tools=[first_page], next_cursor="page-2")
if cursor == "page-2":
return ListToolsResult(tools=[Tool(name="second-page", input_schema={"type": "object"})])
if cursor == "fail":
raise MCPError(code=INTERNAL_ERROR, message="transient failure")
raise MCPError(code=INVALID_PARAMS, message=f"Unknown cursor: {cursor!r}")
server = Server("paginated", on_list_tools=list_tools, cache_hints={"tools/list": CacheHint(ttl_ms=60_000)})
return server, fetches
async def test_cursor_continuations_neither_read_nor_write_the_cache() -> None:
"""Only cursor-less calls participate in caching (SDK-defined single-page entry)."""
server, fetches = _paginated_tools_server()
async with Client(server, cache=CacheConfig(clock=_ManualClock())) as client:
assert _tool_names(await client.list_tools()) == ["first-page"]
assert _tool_names(await client.list_tools(cursor="page-2")) == ["second-page"]
assert _tool_names(await client.list_tools()) == ["first-page"] # not overwritten by the continuation
assert fetches == [None, "page-2"]
async def test_an_expired_cursor_rejection_evicts_the_methods_entry() -> None:
"""Spec SHOULD: INVALID_PARAMS on a continuation cursor means the listing changed."""
server, fetches = _paginated_tools_server()
async with Client(server, cache=CacheConfig(clock=_ManualClock())) as client:
await client.list_tools()
with pytest.raises(MCPError) as exc_info:
await client.list_tools(cursor="expired")
assert exc_info.value.code == INVALID_PARAMS
await client.list_tools()
assert fetches == [None, "expired", None]
async def test_an_expired_cursor_rejection_under_bypass_does_not_evict() -> None:
"""Bypass means no cache side-effects at all, eviction included."""
server, fetches = _paginated_tools_server()
async with Client(server, cache=CacheConfig(clock=_ManualClock())) as client:
await client.list_tools()
with pytest.raises(MCPError) as exc_info:
await client.list_tools(cursor="expired", cache_mode="bypass")
assert exc_info.value.code == INVALID_PARAMS
await client.list_tools() # still served from the warm entry
assert fetches == [None, "expired"]
async def test_a_non_cursor_error_on_a_continuation_does_not_evict() -> None:
"""Only INVALID_PARAMS signals cursor expiry."""
server, fetches = _paginated_tools_server()
async with Client(server, cache=CacheConfig(clock=_ManualClock())) as client:
await client.list_tools()
with pytest.raises(MCPError) as exc_info:
await client.list_tools(cursor="fail")
assert exc_info.value.code == INTERNAL_ERROR
await client.list_tools() # still served from the warm entry
assert fetches == [None, "fail"]
async def test_bypass_neither_serves_nor_disturbs_a_warm_entry() -> None:
server, fetches = _varying_tools_server()
async with Client(server, cache=CacheConfig(clock=_ManualClock())) as client:
assert _tool_names(await client.list_tools()) == ["t0"]
assert _tool_names(await client.list_tools(cache_mode="bypass")) == ["t1"]
assert _tool_names(await client.list_tools()) == ["t0"] # warm entry intact
assert fetches == [None, None]
async def test_refresh_skips_the_read_and_stores_the_refetched_result() -> None:
server, fetches = _varying_tools_server()
async with Client(server, cache=CacheConfig(clock=_ManualClock())) as client:
assert _tool_names(await client.list_tools()) == ["t0"]
assert _tool_names(await client.list_tools(cache_mode="refresh")) == ["t1"]
assert _tool_names(await client.list_tools()) == ["t1"]
assert fetches == [None, None]
async def test_refresh_storing_a_ttl_zero_result_purges_the_warm_entry() -> None:
"""An uncacheable refetch still supersedes the warm entry."""
fetches: list[str | None] = []
async def list_tools(ctx: ServerRequestContext, params: types.PaginatedRequestParams | None) -> ListToolsResult:
fetches.append(params.cursor if params is not None else None)
ttl_ms = 60_000 if len(fetches) == 1 else 0
tool = Tool(name=f"t{len(fetches) - 1}", input_schema={"type": "object"})
return ListToolsResult(tools=[tool], ttl_ms=ttl_ms)
server = Server("flip", on_list_tools=list_tools)
async with Client(server, cache=CacheConfig(clock=_ManualClock())) as client:
assert _tool_names(await client.list_tools()) == ["t0"]
assert _tool_names(await client.list_tools(cache_mode="refresh")) == ["t1"]
assert _tool_names(await client.list_tools()) == ["t2"] # t0 purged, t1 (ttl 0) never stored
assert fetches == [None, None, None]
async def test_a_list_call_carrying_meta_is_fetched_and_replaces_the_warm_entry() -> None:
"""SDK-defined: `meta` (a progress token, tracing fields) expects a wire request,
so under the default "use" the call behaves as a refresh."""
server, fetches = _varying_tools_server()
async with Client(server, cache=CacheConfig(clock=_ManualClock())) as client:
assert _tool_names(await client.list_tools()) == ["t0"]
assert _tool_names(await client.list_tools()) == ["t0"] # warm, meta-less: served
assert _tool_names(await client.list_tools(meta={"progress_token": "tok"})) == ["t1"] # meta: fetched
assert _tool_names(await client.list_tools()) == ["t1"] # the fresh result replaced the entry
assert fetches == [None, None]
async def test_a_read_resource_carrying_meta_is_fetched_and_replaces_the_warm_entry() -> None:
reads: list[str] = []
async def read(ctx: ServerRequestContext, params: types.ReadResourceRequestParams) -> ReadResourceResult:
reads.append(params.uri)
return ReadResourceResult(contents=[TextResourceContents(uri=params.uri, text=f"v{len(reads)}")], ttl_ms=60_000)
server = Server("versioned-reads", on_read_resource=read)
def text(result: ReadResourceResult) -> str:
content = result.contents[0]
assert isinstance(content, TextResourceContents)
return content.text
async with Client(server, cache=CacheConfig(clock=_ManualClock())) as client:
assert text(await client.read_resource("memo://a")) == "v1"
assert text(await client.read_resource("memo://a")) == "v1" # warm, meta-less: served
assert text(await client.read_resource("memo://a", meta={"progress_token": "tok"})) == "v2" # meta: fetched
assert text(await client.read_resource("memo://a")) == "v2" # the fresh result replaced the entry
assert reads == ["memo://a", "memo://a"]
async def test_cache_mode_is_inert_when_caching_is_disabled() -> None:
server, fetches = _varying_tools_server()
async with Client(server, cache=False) as client:
await client.list_tools()
await client.list_tools(cache_mode="use")
await client.list_tools(cache_mode="refresh")
assert fetches == [None, None, None]
@pytest.mark.parametrize(
"seed",
[{"request_state": "round-2"}, {"input_responses": {"ask": ElicitResult(action="decline")}}],
ids=["request_state", "input_responses"],
)
async def test_a_seeded_read_resource_skips_the_cache_and_ignores_cache_mode(seed: dict[str, Any]) -> None:
"""Spec MUST: results of requests carrying `inputResponses` or `requestState` are never cached."""
reads = 0
async def read(ctx: ServerRequestContext, params: types.ReadResourceRequestParams) -> ReadResourceResult:
nonlocal reads
reads += 1
return ReadResourceResult(contents=[TextResourceContents(uri=params.uri, text=f"v{reads}")], ttl_ms=60_000)
server = Server("res", on_read_resource=read)
def text(result: ReadResourceResult) -> str:
content = result.contents[0]
assert isinstance(content, TextResourceContents)
return content.text
async with Client(server, cache=CacheConfig(clock=_ManualClock())) as client:
assert text(await client.read_resource("memo://a")) == "v1"
assert text(await client.read_resource("memo://a", **seed)) == "v2"
assert text(await client.read_resource("memo://a", **seed, cache_mode="refresh")) == "v3"
assert text(await client.read_resource("memo://a")) == "v1" # nothing read, written, or purged
assert reads == 3
async def test_a_terminal_read_reached_through_driver_rounds_is_never_cached() -> None:
"""Spec MUST: the driver's retry rounds carry `inputResponses`, so their terminal result is not cached."""
seeded_rounds: list[bool] = []
ask = ElicitRequest(
params=ElicitRequestFormParams(
message="What is your name?",
requested_schema={"type": "object", "properties": {"name": {"type": "string"}}, "required": ["name"]},
)
)
async def read(
ctx: ServerRequestContext, params: types.ReadResourceRequestParams
) -> ReadResourceResult | InputRequiredResult:
seeded_rounds.append(params.input_responses is not None)
if params.input_responses is not None:
return ReadResourceResult(contents=[TextResourceContents(uri=params.uri, text="terminal")], ttl_ms=60_000)
return InputRequiredResult(input_requests={"ask": ask})
async def elicitation_callback(
context: Any, params: types.ElicitRequestParams
) -> types.ElicitResult | types.ErrorData:
return ElicitResult(action="accept", content={"name": "Ada"})
server = Server("gated", on_read_resource=read)
with anyio.fail_after(5):
async with Client(
server, elicitation_callback=elicitation_callback, cache=CacheConfig(clock=_ManualClock())
) as client:
first = await client.read_resource("memo://gated")
second = await client.read_resource("memo://gated")
assert isinstance(first.contents[0], TextResourceContents) and first.contents[0].text == "terminal"
assert second == first
assert seeded_rounds == [False, True, False, True] # two wire rounds per call: never served
async def test_a_refresh_that_resolves_to_input_required_purges_the_warm_entry() -> None:
"""The refresh cannot store its driven terminal result (the rounds carry
`inputResponses`, a spec MUST), but it still purges the warm entry."""
reads = 0
ask = ElicitRequest(
params=ElicitRequestFormParams(
message="What is your name?",
requested_schema={"type": "object", "properties": {"name": {"type": "string"}}, "required": ["name"]},
)
)
async def read(
ctx: ServerRequestContext, params: types.ReadResourceRequestParams
) -> ReadResourceResult | InputRequiredResult:
nonlocal reads
reads += 1
# Starts plain, then flips to requiring input.
if reads > 1 and params.input_responses is None:
return InputRequiredResult(input_requests={"ask": ask})
return ReadResourceResult(contents=[TextResourceContents(uri=params.uri, text=f"v{reads}")], ttl_ms=60_000)
async def elicitation_callback(
context: Any, params: types.ElicitRequestParams
) -> types.ElicitResult | types.ErrorData:
return ElicitResult(action="accept", content={"name": "Ada"})
server = Server("flipping", on_read_resource=read)
def text(result: ReadResourceResult) -> str:
content = result.contents[0]
assert isinstance(content, TextResourceContents)
return content.text
with anyio.fail_after(5):
async with Client(
server, elicitation_callback=elicitation_callback, cache=CacheConfig(clock=_ManualClock())
) as client:
assert text(await client.read_resource("memo://a")) == "v1" # cached for 60s
assert text(await client.read_resource("memo://a", cache_mode="refresh")) == "v3"
# v1 purged and v3 never stored: the plain read drives fresh rounds.
assert text(await client.read_resource("memo://a")) == "v5"
assert reads == 5
def _output_schema_server(call_result: CallToolResult) -> tuple[Server[Any], list[str | None]]:
"""One tool declaring an output schema; `call_tool` returns the canned `call_result`."""
fetches: list[str | None] = []
tool = Tool(
name="run",
input_schema={"type": "object"},
output_schema={"type": "object", "properties": {"n": {"type": "integer"}}, "required": ["n"]},
)
async def list_tools(ctx: ServerRequestContext, params: types.PaginatedRequestParams | None) -> ListToolsResult:
fetches.append(params.cursor if params is not None else None)
return ListToolsResult(tools=[tool])
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
assert params.name == "run"
return call_result
server = Server(
"schemas",
on_list_tools=list_tools,
on_call_tool=call_tool,
cache_hints={"tools/list": CacheHint(ttl_ms=60_000)},
)
return server, fetches
async def test_a_listing_served_from_a_shared_store_rebuilds_output_schemas() -> None:
"""A served listing is absorbed into the session: output validation works without a wire fetch."""
call_result = CallToolResult(content=[TextContent(text="ok")], structured_content={"n": 1})
server, fetches = _output_schema_server(call_result)
config = CacheConfig(store=InMemoryResponseCacheStore(), partition="p", target_id="svc", clock=_ManualClock())
async with Client(server, cache=config) as warming:
listing = await warming.list_tools()
async with Client(server, cache=config) as fresh:
assert await fresh.list_tools() == listing # served from the shared store
result = await fresh.call_tool("run", {})
assert result.structured_content == {"n": 1}
# A starved schema cache would have re-listed here.
assert fetches == [None]
async def test_validation_from_a_served_listing_rejects_missing_structured_content() -> None:
"""The schema absorbed from a served listing is enforced, not just present."""
server, fetches = _output_schema_server(CallToolResult(content=[TextContent(text="ok")]))
config = CacheConfig(store=InMemoryResponseCacheStore(), partition="p", target_id="svc", clock=_ManualClock())
async with Client(server, cache=config) as warming:
await warming.list_tools()
async with Client(server, cache=config) as fresh:
await fresh.list_tools()
with pytest.raises(RuntimeError) as exc_info:
await fresh.call_tool("run", {})
assert str(exc_info.value) == snapshot("Tool run has an output schema but did not return structured content")
assert fetches == [None]
async def test_a_cache_hit_listing_still_mirrors_x_mcp_headers_on_tools_call() -> None:
"""The arg-to-header maps are rebuilt from a served listing. Asserted at the wire
because the client never surfaces outgoing headers."""
tool = Tool(
name="run",
input_schema={"type": "object", "properties": {"region": {"type": "string", "x-mcp-header": "Region"}}},
)
async def list_tools(ctx: ServerRequestContext, params: types.PaginatedRequestParams | None) -> ListToolsResult:
return ListToolsResult(tools=[tool], ttl_ms=60_000)
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
assert params.name == "run"
return CallToolResult(content=[TextContent(text="ok")])
server = Server("headers", on_list_tools=list_tools, on_call_tool=call_tool)
posts: list[httpx.Request] = []
async def on_request(request: httpx.Request) -> None:
posts.append(request)
config = CacheConfig(store=InMemoryResponseCacheStore(), partition="p", target_id="svc")
discover = DiscoverResult(
supported_versions=[LATEST_MODERN_VERSION],
capabilities=ServerCapabilities(),
server_info=Implementation(name="srv", version="0"),
)
with anyio.fail_after(5):
async with mounted_app(server, on_request=on_request) as (http, _):
warming = Client(
streamable_http_client(f"{BASE_URL}/mcp", http_client=http),
mode=LATEST_MODERN_VERSION,
prior_discover=discover,
cache=config,
)
async with warming:
await warming.list_tools()
fresh = Client(
streamable_http_client(f"{BASE_URL}/mcp", http_client=http),
mode=LATEST_MODERN_VERSION,
prior_discover=discover,
cache=config,
)
async with fresh:
await fresh.list_tools()
await fresh.call_tool("run", {"region": "us-west1"})
# One tools/list on the wire: the fresh client served from the store.
assert [json.loads(request.content)["method"] for request in posts] == ["tools/list", "tools/call"]
assert posts[-1].headers["mcp-param-region"] == "us-west1"
async def test_a_shared_store_hit_prunes_a_header_map_the_writers_filter_dropped() -> None:
"""Cached listings are post-filter: when another client's refresh wrote a listing whose
filter dropped tool `x` (its annotation went invalid), a hit on that entry must prune the
reader's stale arg-to-header map, or it would keep emitting Mcp-Param-* headers for `x`."""
valid = {"type": "object", "properties": {"region": {"type": "string", "x-mcp-header": "Region"}}}
invalid = {"type": "object", "properties": {"region": {"type": "string", "x-mcp-header": "bad name"}}}
schema = valid
async def list_tools(ctx: ServerRequestContext, params: types.PaginatedRequestParams | None) -> ListToolsResult:
return ListToolsResult(tools=[Tool(name="x", input_schema=schema)])
server = Server("filtering", on_list_tools=list_tools, cache_hints={"tools/list": CacheHint(ttl_ms=60_000)})
config = CacheConfig(store=InMemoryResponseCacheStore(), partition="p", target_id="svc", clock=_ManualClock())
with anyio.fail_after(5):
async with Client(server, cache=config) as reader, Client(server, cache=config) as writer:
await reader.list_tools() # fetches while `x` is valid; the reader holds its header map
assert "x" in reader.session._x_mcp_header_maps
schema = invalid
await writer.list_tools(cache_mode="refresh") # the writer's filter drops `x`; the entry is replaced
served = await reader.list_tools() # hit on the writer's entry
assert served.tools == []
assert "x" not in reader.session._x_mcp_header_maps
async def test_a_tools_list_changed_notification_makes_the_next_list_refetch() -> None:
"""Spec SHOULD: list_changed invalidates the cached listing. Legacy session +
`default_ttl_ms` entry: eviction is era-independent."""
fetches: list[str | None] = []
async def list_tools(ctx: ServerRequestContext, params: types.PaginatedRequestParams | None) -> ListToolsResult:
fetches.append(params.cursor if params is not None else None)
return ListToolsResult(tools=[Tool(name="touch", input_schema={"type": "object"})])
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
assert params.name == "touch"
await ctx.session.send_tool_list_changed()
return CallToolResult(content=[TextContent(text="ok")])
server = Server("notify", on_list_tools=list_tools, on_call_tool=call_tool)
# The wrap evicts before delegating: delivery implies eviction completed.
delivered = anyio.Event()
async def on_message(message: IncomingMessage) -> None:
assert isinstance(message, ToolListChangedNotification) # the only message this server emits
delivered.set()
client = Client(server, mode="legacy", cache=CacheConfig(default_ttl_ms=60_000), message_handler=on_message)
async with client:
await client.list_tools()
await client.list_tools()
assert fetches == [None] # cached via default_ttl_ms
await client.call_tool("touch", {})
with anyio.fail_after(5):
await delivered.wait()
await client.list_tools()
assert fetches == [None, None]
async def test_a_resource_updated_notification_evicts_that_uris_read_entry() -> None:
"""Spec SHOULD: resources/updated invalidates the cached read for its uri,
and the notification's `params.uri` must match the stored key's uri form."""
uri = "memo://cached"
reads: list[str] = []
async def read(ctx: ServerRequestContext, params: types.ReadResourceRequestParams) -> ReadResourceResult:
reads.append(params.uri)
return ReadResourceResult(contents=[TextResourceContents(uri=params.uri, text=f"v{len(reads)}")])
async def list_tools(ctx: ServerRequestContext, params: types.PaginatedRequestParams | None) -> ListToolsResult:
return ListToolsResult(tools=[Tool(name="poke", input_schema={"type": "object"})])
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
assert params.name == "poke"
await ctx.session.send_resource_updated(uri)
return CallToolResult(content=[TextContent(text="ok")])
server = Server("updates", on_read_resource=read, on_list_tools=list_tools, on_call_tool=call_tool)
delivered: list[str] = []
seen = anyio.Event()
async def on_message(message: IncomingMessage) -> None:
assert isinstance(message, ResourceUpdatedNotification) # the only message this server emits
delivered.append(message.params.uri)
seen.set()
client = Client(server, mode="legacy", cache=CacheConfig(default_ttl_ms=60_000), message_handler=on_message)
async with client:
await client.read_resource(uri)
await client.read_resource(uri)
assert reads == [uri] # cached via default_ttl_ms
await client.call_tool("poke", {})
with anyio.fail_after(5):
await seen.wait()
await client.read_resource(uri)
assert delivered == [uri] # the exact string the entry was stored under
assert reads == [uri, uri]
async def test_the_modern_in_process_path_drops_the_eviction_notification() -> None:
"""Pins the documented gap: the default in-process path (DirectDispatcher) drops
standalone notifications, so the warm entry survives. If this starts failing the
path gained delivery: flip the `docs/client/caching.md` caveat and the legacy-mode tests."""
fetches: list[str | None] = []
async def list_tools(ctx: ServerRequestContext, params: types.PaginatedRequestParams | None) -> ListToolsResult:
fetches.append(params.cursor if params is not None else None)
return ListToolsResult(tools=[Tool(name="touch", input_schema={"type": "object"})])
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
assert params.name == "touch"
await ctx.session.send_tool_list_changed()
return CallToolResult(content=[TextContent(text="ok")])
server = Server(
"notify",
on_list_tools=list_tools,
on_call_tool=call_tool,
cache_hints={"tools/list": CacheHint(ttl_ms=60_000)},
)
async with Client(server, cache=CacheConfig(clock=_ManualClock())) as client:
await client.list_tools()
await client.call_tool("touch", {})
await client.list_tools() # still served from the warm entry: no eviction arrived
assert fetches == [None]
async def test_a_discover_result_never_enters_the_response_cache() -> None:
"""SDK ruling (documented): the cache covers the five verbs only; a persisted
`prior_discover`'s freshness is the user's bookkeeping."""
server = Server("hinted", cache_hints={"server/discover": CacheHint(ttl_ms=60_000)})
async with Client(server, cache=CacheConfig(clock=_ManualClock())) as client:
discover = client.session.discover_result
assert discover is not None
assert discover.ttl_ms == 60_000 # the hint arrived with the probe result...
store = _coordinator(client)._store
assert isinstance(store, InMemoryResponseCacheStore)
assert store._entries == {} # ...and nothing entered the cache
# --- The inbound ttlMs clamp (parse seam) ---
@pytest.mark.parametrize("wire_ttl", [-5, -5.0])
async def test_a_negative_inbound_ttl_is_served_as_zero_and_never_cached(wire_ttl: int | float) -> None:
"""Spec SHOULD: a negative `ttlMs` is treated as 0, not a wire-validation failure.
Scripted peer: an SDK server enforces `ge=0` and cannot emit one."""
listings_served = 0
async def scripted_server(streams: MessageStream) -> None:
nonlocal listings_served
server_read, server_write = streams
async for message in server_read:
assert isinstance(message, SessionMessage)
frame = message.message
assert isinstance(frame, types.JSONRPCRequest)
if frame.method == "server/discover":
result: dict[str, Any] = {
"supportedVersions": [LATEST_MODERN_VERSION],
"capabilities": {},
"serverInfo": {"name": "negative-ttl", "version": "0.0.1"},
"resultType": "complete",
"ttlMs": 0,
}
else:
assert frame.method == "tools/list"
listings_served += 1
result = {"resultType": "complete", "tools": [], "ttlMs": wire_ttl, "cacheScope": "private"}
await server_write.send(SessionMessage(types.JSONRPCResponse(jsonrpc="2.0", id=frame.id, result=result)))
@asynccontextmanager
async def scripted_transport() -> AsyncIterator[TransportStreams]:
async with (
create_client_server_memory_streams() as ((client_read, client_write), server_streams),
anyio.create_task_group() as tg,
):
tg.start_soon(scripted_server, server_streams)
yield client_read, client_write
tg.cancel_scope.cancel()
with anyio.fail_after(5):
async with Client(scripted_transport(), mode="auto") as client:
first = await client.list_tools()
second = await client.list_tools()
assert first.ttl_ms == 0
assert second.ttl_ms == 0
assert listings_served == 2 # the clamped-to-zero ttl was never stored
@pytest.mark.parametrize("wire_ttl", [-5, -5.0])
async def test_a_negative_discover_ttl_still_connects_modern_in_auto_mode(wire_ttl: int | float) -> None:
"""Regression: pre-clamp, a negative discover `ttlMs` failed validation inside the
mode="auto" probe and silently downgraded to the legacy handshake."""
methods_seen: list[str] = []
async def scripted_server(streams: MessageStream) -> None:
server_read, server_write = streams
async for message in server_read:
assert isinstance(message, SessionMessage)
frame = message.message
assert isinstance(frame, types.JSONRPCRequest)
methods_seen.append(frame.method)
# A legacy downgrade would send `initialize`; fail loudly instead.
assert frame.method == "server/discover"
result: dict[str, Any] = {
"supportedVersions": [LATEST_MODERN_VERSION],
"capabilities": {},
"serverInfo": {"name": "negative-ttl", "version": "0.0.1"},
"resultType": "complete",
"ttlMs": wire_ttl,
}
await server_write.send(SessionMessage(types.JSONRPCResponse(jsonrpc="2.0", id=frame.id, result=result)))
@asynccontextmanager
async def scripted_transport() -> AsyncIterator[TransportStreams]:
async with (
create_client_server_memory_streams() as ((client_read, client_write), server_streams),
anyio.create_task_group() as tg,
):
tg.start_soon(scripted_server, server_streams)
yield client_read, client_write
tg.cancel_scope.cancel()
with anyio.fail_after(5):
async with Client(scripted_transport(), mode="auto") as client:
assert client.protocol_version == LATEST_MODERN_VERSION
discover = client.session.discover_result
assert discover is not None
assert discover.ttl_ms == 0
assert methods_seen == ["server/discover"]
# --- Hardening e2e ---
def _versioned_read_server(*, ttl_ms: int = 60_000) -> tuple[Server[Any], list[str]]:
"""Server whose every read returns a distinct payload `v<n>`,
so a served entry is distinguishable from a refetch."""
reads: list[str] = []
async def read(ctx: ServerRequestContext, params: types.ReadResourceRequestParams) -> ReadResourceResult:
reads.append(params.uri)
return ReadResourceResult(contents=[TextResourceContents(uri=params.uri, text=f"v{len(reads)}")], ttl_ms=ttl_ms)
return Server("versioned-reads", on_read_resource=read), reads
def _resource_text(result: ReadResourceResult) -> str:
content = result.contents[0]
assert isinstance(content, TextResourceContents)
return content.text
async def test_each_notification_evicts_exactly_its_entries_end_to_end() -> None:
"""Spec SHOULD (notifications invalidate) plus its negative space: each notification
refetches exactly its own entries, and resources/list_changed also covers templates."""
uri_x, uri_y = "memo://x", "memo://y"
fetched: list[str] = []
async def list_tools(ctx: ServerRequestContext, params: types.PaginatedRequestParams | None) -> ListToolsResult:
fetched.append("tools/list")
return ListToolsResult(tools=[Tool(name="notify", input_schema={"type": "object"})])
async def list_prompts(ctx: ServerRequestContext, params: types.PaginatedRequestParams | None) -> ListPromptsResult:
fetched.append("prompts/list")
return ListPromptsResult(prompts=[])
async def list_resources(
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
) -> ListResourcesResult:
fetched.append("resources/list")
return ListResourcesResult(resources=[])
async def list_templates(
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
) -> ListResourceTemplatesResult:
fetched.append("resources/templates/list")
return ListResourceTemplatesResult(resource_templates=[])
async def read(ctx: ServerRequestContext, params: types.ReadResourceRequestParams) -> ReadResourceResult:
fetched.append(f"resources/read {params.uri}")
return ReadResourceResult(contents=[TextResourceContents(uri=params.uri, text="body")])
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
assert params.name == "notify"
kind = (params.arguments or {})["kind"]
if kind == "tools":
await ctx.session.send_tool_list_changed()
elif kind == "resources":
await ctx.session.send_resource_list_changed()
else:
assert kind == "updated-x"
await ctx.session.send_resource_updated(uri_x)
return CallToolResult(content=[TextContent(text="sent")])
server = Server(
"notifier",
on_list_tools=list_tools,
on_list_prompts=list_prompts,
on_list_resources=list_resources,
on_list_resource_templates=list_templates,
on_read_resource=read,
on_call_tool=call_tool,
)
delivered: list[IncomingMessage] = []
eviction_done = [anyio.Event() for _ in range(3)]
async def on_message(message: IncomingMessage) -> None:
# The wrap evicts before delegating: each event implies its eviction completed.
delivered.append(message)
eviction_done[len(delivered) - 1].set()
client = Client(
server,
mode="legacy",
cache=CacheConfig(default_ttl_ms=60_000, clock=_ManualClock()),
message_handler=on_message,
)
async with client:
async def served_round() -> list[str]:
"""Call every cacheable verb once; return the calls that reached the server."""
before = len(fetched)
await client.list_tools()
await client.list_prompts()
await client.list_resources()
await client.list_resource_templates()
await client.read_resource(uri_x)
await client.read_resource(uri_y)
return fetched[before:]
assert await served_round() == [
"tools/list",
"prompts/list",
"resources/list",
"resources/templates/list",
f"resources/read {uri_x}",
f"resources/read {uri_y}",
]
assert await served_round() == [] # everything primed and served
await client.call_tool("notify", {"kind": "tools"})
with anyio.fail_after(5):
await eviction_done[0].wait()
assert await served_round() == ["tools/list"]
await client.call_tool("notify", {"kind": "resources"})
with anyio.fail_after(5):
await eviction_done[1].wait()
assert await served_round() == ["resources/list", "resources/templates/list"]
await client.call_tool("notify", {"kind": "updated-x"})
with anyio.fail_after(5):
await eviction_done[2].wait()
assert await served_round() == [f"resources/read {uri_x}"]
assert delivered == [
ToolListChangedNotification(),
ResourceListChangedNotification(),
ResourceUpdatedNotification(params=ResourceUpdatedNotificationParams(uri=uri_x)),
]
async def test_private_entries_never_cross_partitions_between_clients_sharing_a_store() -> None:
"""Spec MUST: "private" never crosses authorization contexts."""
server, fetches = _varying_tools_server()
store = InMemoryResponseCacheStore()
def config(partition: str) -> CacheConfig:
return CacheConfig(store=store, partition=partition, target_id="svc", clock=_ManualClock())
async with Client(server, cache=config("tenant-a")) as tenant_a:
assert _tool_names(await tenant_a.list_tools()) == ["t0"]
async with Client(server, cache=config("tenant-b")) as tenant_b:
assert _tool_names(await tenant_b.list_tools()) == ["t1"] # fetched, not tenant-a's entry
assert fetches == [None, None]
async def test_a_server_stamped_public_entry_does_not_cross_partitions_by_default() -> None:
"""SDK security default (deviates from the ts SDK): the public arm is still keyed by partition."""
server, fetches = _varying_tools_server(scope="public")
store = InMemoryResponseCacheStore()
def config(partition: str) -> CacheConfig:
return CacheConfig(store=store, partition=partition, target_id="svc", clock=_ManualClock())
async with Client(server, cache=config("tenant-a")) as tenant_a:
assert _tool_names(await tenant_a.list_tools()) == ["t0"]
async with Client(server, cache=config("tenant-a")) as same_partition:
assert _tool_names(await same_partition.list_tools()) == ["t0"] # served from the store
async with Client(server, cache=config("tenant-b")) as tenant_b:
assert _tool_names(await tenant_b.list_tools()) == ["t1"] # fetched
assert fetches == [None, None]
async def test_share_public_serves_a_server_stamped_public_entry_across_partitions() -> None:
"""With `share_public=True` the public arm drops the partition."""
server, fetches = _varying_tools_server(scope="public")
store = InMemoryResponseCacheStore()
def config(partition: str) -> CacheConfig:
return CacheConfig(store=store, partition=partition, target_id="svc", share_public=True, clock=_ManualClock())
async with Client(server, cache=config("tenant-a")) as tenant_a:
assert _tool_names(await tenant_a.list_tools()) == ["t0"]
async with Client(server, cache=config("tenant-b")) as tenant_b:
assert _tool_names(await tenant_b.list_tools()) == ["t0"] # served across partitions
assert fetches == [None]
async def test_same_partition_clients_share_read_entries_through_the_store() -> None:
server, reads = _versioned_read_server()
store = InMemoryResponseCacheStore()
def config() -> CacheConfig:
return CacheConfig(store=store, partition="p", target_id="svc", clock=_ManualClock())
async with Client(server, cache=config()) as first:
first_result = await first.read_resource("memo://a")
async with Client(server, cache=config()) as second:
assert await second.read_resource("memo://a") == first_result
assert reads == ["memo://a"]
async def test_mutating_returned_results_never_corrupts_the_cached_entry() -> None:
"""Deep-copy isolation in both directions: write-side (the fetched result) and
serve-side (the served hit)."""
server, fetches = _varying_tools_server()
async with Client(server, cache=CacheConfig(clock=_ManualClock())) as client:
first = await client.list_tools()
first.tools[0].name = "tampered-after-fetch"
second = await client.list_tools() # cache hit, unaffected by the mutation
assert _tool_names(second) == ["t0"]
second.tools[0].name = "tampered-after-serve"
assert _tool_names(await client.list_tools()) == ["t0"] # still pristine
assert fetches == [None]
async def test_a_cache_hit_still_yields_to_the_event_loop() -> None:
"""A hit completes without a wire await, so the verb checkpoints explicitly: a poll
loop over a fresh entry would otherwise starve spawned tasks (eviction dispatch).
Pinned by calling a warm verb inside an already-cancelled scope: only a yield can
observe the cancellation."""
server, fetches = _varying_tools_server()
async with Client(server, cache=CacheConfig(clock=_ManualClock())) as client:
assert _tool_names(await client.list_tools()) == ["t0"] # warm the entry
with anyio.CancelScope() as scope:
scope.cancel()
await client.list_tools() # would be a hit; must yield and observe the cancellation
assert scope.cancelled_caught
assert fetches == [None] # the cancelled call neither fetched nor served
async def test_a_legacy_peer_injecting_cache_hints_caches_nothing() -> None:
"""Era gate: hint keys a 2025 peer puts on the wire cache nothing. Scripted peer:
an SDK server strips the hint fields when serializing for a 2025 session."""
listings_served = 0
async def scripted_server(streams: MessageStream) -> None:
nonlocal listings_served
server_read, server_write = streams
async for message in server_read:
assert isinstance(message, SessionMessage)
frame = message.message
if isinstance(frame, types.JSONRPCNotification):
assert frame.method == "notifications/initialized"
continue
assert isinstance(frame, types.JSONRPCRequest)
if frame.method == "initialize":
result: dict[str, Any] = {
"protocolVersion": "2025-11-25",
"capabilities": {},
"serverInfo": {"name": "legacy-injector", "version": "0.0.1"},
}
else:
assert frame.method == "tools/list"
listings_served += 1
result = {"tools": [], "ttlMs": 60_000, "cacheScope": "public"}
await server_write.send(SessionMessage(types.JSONRPCResponse(jsonrpc="2.0", id=frame.id, result=result)))
@asynccontextmanager
async def scripted_transport() -> AsyncIterator[TransportStreams]:
async with (
create_client_server_memory_streams() as ((client_read, client_write), server_streams),
anyio.create_task_group() as tg,
):
tg.start_soon(scripted_server, server_streams)
yield client_read, client_write
tg.cancel_scope.cancel()
with anyio.fail_after(5):
async with Client(scripted_transport(), mode="legacy", cache=CacheConfig(clock=_ManualClock())) as client:
await client.list_tools()
await client.list_tools()
store = _coordinator(client)._store
assert isinstance(store, InMemoryResponseCacheStore)
assert store._entries == {} # neither arm holds an entry
assert listings_served == 2
class _CancelOnSetStore(InMemoryResponseCacheStore):
"""Store whose next `set` awaits a one-shot hook before committing."""
def __init__(self) -> None:
super().__init__()
self.before_set: Callable[[], Awaitable[None]] | None = None
async def set(self, key: CacheKey, entry: CacheEntry) -> None:
if self.before_set is not None:
hook, self.before_set = self.before_set, None
await hook()
await super().set(key, entry)
async def test_a_verb_cancelled_mid_write_leaves_no_stale_arm_pair() -> None:
"""No-stale-pair invariant: a cancellation between the opposite-arm delete and the
`set` commit leaves at most one entry per key, so the superseded entry cannot be served."""
fetches: list[str | None] = []
async def list_tools(ctx: ServerRequestContext, params: types.PaginatedRequestParams | None) -> ListToolsResult:
fetches.append(params.cursor if params is not None else None)
scope: Literal["public", "private"] = "public" if len(fetches) == 1 else "private"
tool = Tool(name=f"t{len(fetches) - 1}", input_schema={"type": "object"})
return ListToolsResult(tools=[tool], ttl_ms=60_000, cache_scope=scope)
server = Server("scope-flip", on_list_tools=list_tools)
store = _CancelOnSetStore()
client = Client(server, cache=CacheConfig(store=store, partition="p", target_id="svc", clock=_ManualClock()))
async with client:
assert _tool_names(await client.list_tools()) == ["t0"]
assert len(store._entries) == 1 # the public-arm entry
with anyio.CancelScope() as scope:
async def cancel_mid_commit() -> None:
scope.cancel()
await anyio.lowlevel.checkpoint() # the cancellation is delivered here, inside `set`
store.before_set = cancel_mid_commit
await client.list_tools(cache_mode="refresh")
assert scope.cancelled_caught
# The opposite (public) arm was deleted before the cancelled set could commit.
assert store._entries == {}
assert _tool_names(await client.list_tools()) == ["t2"] # nothing cached: refetched
assert fetches == [None, None, None]
async def test_an_eviction_landing_mid_fetch_discards_that_fetchs_write() -> None:
"""Spec-aligned race rule: an eviction landing mid-fetch discards that fetch's write.
The server waits for the client-side eviction before responding, so the interleaving
is deterministic, not scheduler-dependent."""
fetches: list[str | None] = []
evicted = anyio.Event()
async def list_tools(ctx: ServerRequestContext, params: types.PaginatedRequestParams | None) -> ListToolsResult:
fetches.append(params.cursor if params is not None else None)
if len(fetches) == 1:
await ctx.session.send_tool_list_changed()
with anyio.fail_after(5):
await evicted.wait()
return ListToolsResult(tools=[Tool(name=f"t{len(fetches) - 1}", input_schema={"type": "object"})])
async def on_message(message: IncomingMessage) -> None:
assert isinstance(message, ToolListChangedNotification) # the only message this server emits
evicted.set()
server = Server("racer", on_list_tools=list_tools)
client = Client(
server,
mode="legacy",
cache=CacheConfig(default_ttl_ms=60_000, clock=_ManualClock()),
message_handler=on_message,
)
async with client:
assert _tool_names(await client.list_tools()) == ["t0"]
# Empty proves the write was skipped, not stored-then-evicted: the eviction
# completed strictly before the response, the write strictly after.
store = _coordinator(client)._store
assert isinstance(store, InMemoryResponseCacheStore)
assert store._entries == {}
assert _tool_names(await client.list_tools()) == ["t1"] # refetched...
assert _tool_names(await client.list_tools()) == ["t1"] # ...and that fetch cached normally
assert fetches == [None, None]
async def test_read_resource_bypass_neither_serves_nor_disturbs_a_warm_entry() -> None:
server, reads = _versioned_read_server()
async with Client(server, cache=CacheConfig(clock=_ManualClock())) as client:
assert _resource_text(await client.read_resource("memo://a")) == "v1"
assert _resource_text(await client.read_resource("memo://a", cache_mode="bypass")) == "v2"
assert _resource_text(await client.read_resource("memo://a")) == "v1" # warm entry intact
assert reads == ["memo://a", "memo://a"]
async def test_read_resource_refresh_refetches_and_restores() -> None:
server, reads = _versioned_read_server()
async with Client(server, cache=CacheConfig(clock=_ManualClock())) as client:
assert _resource_text(await client.read_resource("memo://a")) == "v1"
assert _resource_text(await client.read_resource("memo://a", cache_mode="refresh")) == "v2"
assert _resource_text(await client.read_resource("memo://a")) == "v2" # the refreshed value re-stored
assert reads == ["memo://a", "memo://a"]
async def test_a_closed_client_raises_on_every_cacheable_verb_instead_of_serving_the_cache() -> None:
"""Cache participation requires a live session."""
fetched: list[str] = []
async def list_tools(ctx: ServerRequestContext, params: types.PaginatedRequestParams | None) -> ListToolsResult:
fetched.append("tools/list")
return ListToolsResult(tools=[])
async def list_prompts(ctx: ServerRequestContext, params: types.PaginatedRequestParams | None) -> ListPromptsResult:
fetched.append("prompts/list")
return ListPromptsResult(prompts=[])
async def list_resources(
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
) -> ListResourcesResult:
fetched.append("resources/list")
return ListResourcesResult(resources=[])
async def list_templates(
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
) -> ListResourceTemplatesResult:
fetched.append("resources/templates/list")
return ListResourceTemplatesResult(resource_templates=[])
async def read(ctx: ServerRequestContext, params: types.ReadResourceRequestParams) -> ReadResourceResult:
fetched.append(f"resources/read {params.uri}")
return ReadResourceResult(contents=[TextResourceContents(uri=params.uri, text="body")])
hint = CacheHint(ttl_ms=60_000)
server = Server(
"warm",
on_list_tools=list_tools,
on_list_prompts=list_prompts,
on_list_resources=list_resources,
on_list_resource_templates=list_templates,
on_read_resource=read,
cache_hints={
"tools/list": hint,
"prompts/list": hint,
"resources/list": hint,
"resources/templates/list": hint,
"resources/read": hint,
},
)
client = Client(server, cache=CacheConfig(clock=_ManualClock()))
async with client:
await client.list_tools()
await client.list_prompts()
await client.list_resources()
await client.list_resource_templates()
await client.read_resource("memo://a")
# A repeat round is served entirely from the warm entries.
await client.list_tools()
await client.read_resource("memo://a")
assert len(fetched) == 5
with pytest.raises(RuntimeError) as exc_info:
await client.list_tools()
assert str(exc_info.value) == snapshot("Client must be used within an async context manager")
with pytest.raises(RuntimeError):
await client.list_prompts()
with pytest.raises(RuntimeError):
await client.list_resources()
with pytest.raises(RuntimeError):
await client.list_resource_templates()
with pytest.raises(RuntimeError):
await client.read_resource("memo://a")
assert len(fetched) == 5 # nothing was served from the cache and nothing reached the server