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
1580 lines
66 KiB
Python
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
|