Skip to content

Commit 20a2d64

Browse files
committed
Document the client-side story for caching hints
The caching page covered only server authoring. Add a 'What the client sees' section: the hints arrive as parsed ttl_ms/cache_scope fields on every cacheable result, the SDK does not act on them, and the supported path today is reading the fields and doing your own freshness and scope bookkeeping. Covers the legacy-server case (absent fields show the conservative model defaults) and the model_fields_set wire-presence check, with a tested example.
1 parent d8d86dd commit 20a2d64

3 files changed

Lines changed: 48 additions & 1 deletion

File tree

docs/advanced/caching.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,22 @@ This is also the escape hatch for dynamics the constructor can't know: a handler
3535

3636
One caveat on paginated lists: the protocol requires the **same `cacheScope` on every page** of one list. The constructor map satisfies that by construction — it's keyed by method, not by page. But a handler that overrides the scope itself owns that consistency: override it on *every* page, never only when a cursor is present, or page one and page two will disagree.
3737

38+
## What the client sees
39+
40+
On the client, the hints arrive as plain fields on every cacheable result — `ttl_ms` and `cache_scope`, already parsed:
41+
42+
```python title="client.py" hl_lines="15"
43+
--8<-- "docs_src/caching/tutorial003.py"
44+
```
45+
46+
The SDK parses; it does not (yet) act. There is no built-in response cache: calling `list_tools()` twice makes two round trips, whatever the TTL said. The spec makes honoring optional — a client that ignores the hints entirely is fully conformant — so until the SDK grows a response cache, the supported path is to read the fields and do your own bookkeeping:
47+
48+
* **Freshness** is `now < t_received + ttl_ms / 1000`: record the clock when the response arrives, and treat the result as reusable until the TTL runs out. `ttl_ms == 0` means *immediately stale* — don't reuse it at all.
49+
* **Scope is a sharing rule, not a suggestion.** A `"private"` result may be reused only within the same authorization context — same access token, same cache. Never put `"private"` results in a cache shared across users.
50+
* **Notifications beat TTL.** If the server sends `list_changed` while your copy is still fresh, the copy is stale now — re-fetch.
51+
52+
Against an **older server** (pre-2026 protocol), the fields are simply absent from the wire, and the models show their conservative defaults: `ttl_ms == 0`, `cache_scope == "private"` — stale and unshared, the right assumption for a server that declared nothing. If you need to distinguish "the server said 0" from "the server said nothing", check `"ttl_ms" in result.model_fields_set`: it's only set when the field actually arrived.
53+
3854
## Older clients
3955

4056
Clients on pre-2026 protocol versions never see either field — the SDK strips them at serialization for those connections. Configure your hints once; there is nothing version-specific to write.
@@ -45,3 +61,4 @@ Clients on pre-2026 protocol versions never see either field — the SDK strips
4561
* `cache_hints={method: CacheHint(...)}` at construction (both `MCPServer` and `Server`) sets server-wide values per method.
4662
* A handler that sets the fields on its result overrides the map, per field.
4763
* `"public"` is a promise that the result is identical for every caller. It is not access control.
64+
* Clients read the hints as `result.ttl_ms` / `result.cache_scope` and own the caching decision themselves — the SDK has no built-in response cache yet.

docs_src/caching/tutorial003.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
from mcp import Client
2+
from mcp.server import CacheHint, MCPServer
3+
4+
mcp = MCPServer("Weather", cache_hints={"tools/list": CacheHint(ttl_ms=60_000, scope="public")})
5+
6+
7+
@mcp.tool()
8+
def forecast(city: str) -> str:
9+
return f"Sunny in {city}"
10+
11+
12+
async def main() -> None:
13+
async with Client(mcp) as client:
14+
tools = await client.list_tools()
15+
print(f"{len(tools.tools)} tools, fresh for {tools.ttl_ms / 1000:.0f}s, scope={tools.cache_scope}")

tests/docs_src/test_caching.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
import pytest
66
from inline_snapshot import snapshot
77

8-
from docs_src.caching import tutorial001, tutorial002
8+
from docs_src.caching import tutorial001, tutorial002, tutorial003
99
from mcp import Client
1010
from mcp.server import CacheHint, MCPServer
1111

@@ -53,3 +53,18 @@ async def test_the_handler_value_wins_over_the_map_per_field() -> None:
5353
tools = await client.list_tools()
5454
assert tools.ttl_ms == 1_000
5555
assert tools.cache_scope == "public"
56+
57+
58+
async def test_the_client_program_on_the_page_reads_the_hints(capsys: pytest.CaptureFixture[str]) -> None:
59+
"""tutorial003: `main()` is the literal client program on the page - the hints
60+
arrive as parsed fields on the result."""
61+
await tutorial003.main()
62+
assert capsys.readouterr().out == "1 tools, fresh for 60s, scope=public\n"
63+
64+
65+
async def test_the_wire_presence_check_the_page_recommends_works() -> None:
66+
"""The page's claim: `"ttl_ms" in result.model_fields_set` distinguishes a
67+
server that sent the field from one that said nothing (model defaults)."""
68+
async with Client(tutorial003.mcp) as client:
69+
tools = await client.list_tools()
70+
assert "ttl_ms" in tools.model_fields_set

0 commit comments

Comments
 (0)