-
Notifications
You must be signed in to change notification settings - Fork 3.7k
Expand file tree
/
Copy pathtest_caching.py
More file actions
209 lines (163 loc) · 8.93 KB
/
Copy pathtest_caching.py
File metadata and controls
209 lines (163 loc) · 8.93 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
"""`docs/client/caching.md`: every claim the page makes, proved against the real SDK."""
from collections.abc import Mapping
from typing import Any, cast
import anyio
import pytest
from inline_snapshot import snapshot
from mcp_types import INTERNAL_ERROR, ListToolsResult, PaginatedRequestParams, Tool
from docs_src.caching import tutorial001, tutorial002, tutorial003
from mcp import Client, MCPError
from mcp.client import CacheConfig
from mcp.client.caching import InMemoryResponseCacheStore
from mcp.server import CacheHint, MCPServer, Server, ServerRequestContext
from mcp.server.caching import CacheableMethod
# See test_index.py for why this is a per-module mark and not a conftest hook.
pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")]
async def test_a_mapped_method_carries_the_configured_hint() -> None:
"""tutorial001: `tools/list` is in the map, so clients see one minute, public."""
async with Client(tutorial001.mcp) as client:
tools = await client.list_tools()
assert tools.ttl_ms == 60_000
assert tools.cache_scope == "public"
async def test_a_hint_without_a_scope_stays_private() -> None:
"""tutorial001: `resources/read` set only `ttl_ms`; scope keeps the conservative default."""
async with Client(tutorial001.mcp) as client:
result = await client.read_resource("config://units")
assert result.ttl_ms == 5_000
assert result.cache_scope == "private"
async def test_an_unmapped_method_stays_immediately_stale_and_private() -> None:
"""tutorial001: `resources/list` is not in the map - the defaults hold."""
async with Client(tutorial001.mcp) as client:
resources = await client.list_resources()
assert resources.ttl_ms == 0
assert resources.cache_scope == "private"
async def test_a_non_cacheable_method_is_rejected_at_construction() -> None:
"""The page's claim: anything but the six cacheable methods raises at construction."""
with pytest.raises(ValueError) as exc:
MCPServer("Weather", cache_hints=cast(Any, {"tools/call": CacheHint(ttl_ms=1_000)}))
assert str(exc.value) == snapshot(
"cache_hints keys must be cacheable methods (see CacheableMethod); got: 'tools/call'"
)
async def test_the_handler_value_wins_over_the_map_per_field() -> None:
"""tutorial002: the handler's `ttl_ms=1_000` beats the map's `60_000`; the scope
the handler left unset takes the map's `"public"`."""
async with Client(tutorial002.server) as client:
tools = await client.list_tools()
assert tools.ttl_ms == 1_000
assert tools.cache_scope == "public"
async def test_the_client_program_on_the_page_makes_three_fetches_for_four_calls(
capsys: pytest.CaptureFixture[str],
) -> None:
"""tutorial003: a cache hit, an expiry, and `cache_mode="refresh"` make four calls cost three fetches."""
await tutorial003.main()
assert capsys.readouterr().out == "4 calls, 3 fetches\n"
def _counting_tools_server(*, ttl_ms: int | None = 60_000) -> tuple[Server[Any], list[str | None]]:
"""Each tools/list fetch returns a distinct tool name, so a cache hit is
payload-distinguishable from a refetch; `ttl_ms=None` sends no hints."""
fetches: list[str | None] = []
async def list_tools(ctx: ServerRequestContext[Any], params: PaginatedRequestParams | None) -> ListToolsResult:
fetches.append(params.cursor if params is not None else None)
return ListToolsResult(tools=[Tool(name=f"t{len(fetches) - 1}", input_schema={"type": "object"})])
hints: Mapping[CacheableMethod, CacheHint] | None = None
if ttl_ms is not None:
hints = {"tools/list": CacheHint(ttl_ms=ttl_ms)}
return Server("counting", on_list_tools=list_tools, cache_hints=hints), fetches
async def test_caching_is_on_by_default_the_second_call_makes_no_fetch() -> None:
server, fetches = _counting_tools_server()
async with Client(server) as client:
first = await client.list_tools()
second = await client.list_tools()
assert fetches == [None]
assert second == first
async def test_a_hintless_result_is_not_cached_by_default() -> None:
"""`default_ttl_ms` defaults to 0, so a hintless server sees its usual call-for-call traffic."""
server, fetches = _counting_tools_server(ttl_ms=None)
async with Client(server) as client:
await client.list_tools()
await client.list_tools()
assert fetches == [None, None]
async def test_cache_none_makes_every_call_a_round_trip() -> None:
server, fetches = _counting_tools_server()
async with Client(server, cache=None) as client:
await client.list_tools()
await client.list_tools()
assert fetches == [None, None]
async def test_refresh_refetches_and_replaces_the_cached_entry() -> None:
server, fetches = _counting_tools_server()
async with Client(server) as client:
await client.list_tools()
refreshed = await client.list_tools(cache_mode="refresh")
served = await client.list_tools()
assert fetches == [None, None]
assert [tool.name for tool in refreshed.tools] == ["t1"]
assert served == refreshed
async def test_bypass_fetches_without_reading_or_writing_the_cache() -> None:
server, fetches = _counting_tools_server()
async with Client(server) as client:
first = await client.list_tools()
bypassed = await client.list_tools(cache_mode="bypass")
served = await client.list_tools()
assert fetches == [None, None]
assert [tool.name for tool in bypassed.tools] == ["t1"]
assert served == first
async def test_an_expired_entry_is_not_revived_when_the_refetch_fails() -> None:
"""SDK ruling: no stale-if-error - the refetch failure propagates."""
now = 1_000_000.0
fetches: list[None] = []
async def list_tools(ctx: ServerRequestContext[Any], params: PaginatedRequestParams | None) -> ListToolsResult:
fetches.append(None)
if len(fetches) > 1:
raise MCPError(code=INTERNAL_ERROR, message="backend down")
return ListToolsResult(tools=[Tool(name="t0", input_schema={"type": "object"})])
server = Server("flaky", on_list_tools=list_tools, cache_hints={"tools/list": CacheHint(ttl_ms=60_000)})
async with Client(server, cache=CacheConfig(clock=lambda: now)) as client:
await client.list_tools()
now += 60.0 # past the 60s TTL
with pytest.raises(MCPError) as exc:
await client.list_tools()
assert exc.value.code == INTERNAL_ERROR
assert len(fetches) == 2
async def test_two_concurrent_identical_calls_are_two_fetches() -> None:
"""SDK ruling: no coalescing. The handler barrier releases only once both
calls are inside it, so the test passes only if the fetches were concurrent."""
both_fetching = anyio.Event()
fetches: list[None] = []
async def list_tools(ctx: ServerRequestContext[Any], params: PaginatedRequestParams | None) -> ListToolsResult:
fetches.append(None)
if len(fetches) == 2:
both_fetching.set()
with anyio.fail_after(5):
await both_fetching.wait()
return ListToolsResult(tools=[Tool(name="t", input_schema={"type": "object"})])
server = Server("concurrent", on_list_tools=list_tools, cache_hints={"tools/list": CacheHint(ttl_ms=60_000)})
async with Client(server) as client:
async with anyio.create_task_group() as tg:
tg.start_soon(client.list_tools)
tg.start_soon(client.list_tools)
assert len(fetches) == 2
async def test_a_session_tier_call_always_makes_the_round_trip() -> None:
"""The cache lives on the `Client` verbs; `client.session` sits below it."""
server, fetches = _counting_tools_server()
async with Client(server) as client:
await client.list_tools()
await client.session.list_tools()
assert fetches == [None, None]
async def test_a_custom_store_requires_a_partition() -> None:
with pytest.raises(ValueError) as exc:
CacheConfig(store=InMemoryResponseCacheStore())
assert str(exc.value) == snapshot("a custom store requires an explicit partition")
async def test_a_custom_store_with_an_in_process_server_requires_target_id() -> None:
server, _ = _counting_tools_server()
with pytest.raises(ValueError) as exc:
Client(server, cache=CacheConfig(store=InMemoryResponseCacheStore(), partition="user-1"))
assert str(exc.value) == snapshot(
"a custom cache store requires CacheConfig.target_id when the server is not a URL: in-process servers "
"and Transport instances get a random per-client identity, so their entries in a shared store could "
"never be served to another client"
)
async def test_the_wire_presence_check_the_page_recommends_works() -> None:
"""The page's claim: `"ttl_ms" in result.model_fields_set` distinguishes a
server that sent the field from one that said nothing (model defaults)."""
async with Client(tutorial001.mcp) as client:
tools = await client.list_tools()
assert "ttl_ms" in tools.model_fields_set