Skip to content

Commit b15b1d5

Browse files
authored
Add a client-side response cache honoring SEP-2549 caching hints (#3023)
1 parent 67d7593 commit b15b1d5

18 files changed

Lines changed: 3789 additions & 88 deletions

File tree

docs/advanced/caching.md

Lines changed: 70 additions & 17 deletions
Large diffs are not rendered by default.

docs/migration.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -427,6 +427,10 @@ On `ClientSession`, `call_tool` / `get_prompt` / `read_resource` still return th
427427

428428
For protocol 2026-07-28 over Streamable HTTP, a tool's input-schema property may carry an `x-mcp-header` annotation. When a tool the client has listed is called, each annotated argument is mirrored into an `Mcp-Param-<name>` request header (string verbatim, integer as decimal, boolean as `true`/`false`, base64-sentinel-wrapped when not header-safe; `null`/absent arguments are omitted). The argument is also left in the request body. `list_tools` caches a tool's annotations, so list a tool before calling it to enable mirroring; a tool the client never listed emits no `Mcp-Param-*` headers. Other transports ignore the annotation.
429429

430+
### `Client` verbs may serve cached responses ([SEP-2549](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2549))
431+
432+
On protocol 2026-07-28, servers attach caching hints (`ttlMs`, `cacheScope`) to the cacheable results, and `Client` now honors them: `list_tools`, `list_prompts`, `list_resources`, `list_resource_templates`, and `read_resource` may serve a cached response instead of making a round trip, for as long as the server's `ttlMs` says the result is fresh. With the default configuration, servers that send no hints, including every pre-2026 server, see identical call-for-call behavior, because hint-less results are not cached (a `CacheConfig.default_ttl_ms` above zero caches them too). Pass `Client(..., cache=False)` to disable the cache and restore v1 behavior exactly; per-call control (`cache_mode`) and configuration (`CacheConfig`) are described in [Caching hints](advanced/caching.md).
433+
430434
### Server extensions API ([SEP-2133](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2133))
431435

432436
`MCPServer` now accepts opt-in extensions that bundle MCP behaviour behind a

docs_src/caching/tutorial003.py

Lines changed: 33 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,40 @@
1+
from dataclasses import dataclass
2+
from typing import Any
3+
4+
from mcp_types import ListToolsResult, PaginatedRequestParams, Tool
5+
16
from mcp import Client
2-
from mcp.server import CacheHint, MCPServer
7+
from mcp.client import CacheConfig
8+
from mcp.server import CacheHint, Server, ServerRequestContext
9+
10+
11+
@dataclass
12+
class DemoState:
13+
fetches: int = 0
14+
now: float = 1_000_000.0
15+
16+
17+
state = DemoState()
18+
319

4-
mcp = MCPServer("Weather", cache_hints={"tools/list": CacheHint(ttl_ms=60_000, scope="public")})
20+
async def list_tools(ctx: ServerRequestContext[Any], params: PaginatedRequestParams | None) -> ListToolsResult:
21+
state.fetches += 1
22+
return ListToolsResult(tools=[Tool(name="forecast", input_schema={"type": "object"})])
523

624

7-
@mcp.tool()
8-
def forecast(city: str) -> str:
9-
return f"Sunny in {city}"
25+
server = Server(
26+
"Weather",
27+
on_list_tools=list_tools,
28+
cache_hints={"tools/list": CacheHint(ttl_ms=60_000, scope="public")},
29+
)
1030

1131

1232
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}")
33+
start = state.fetches
34+
async with Client(server, cache=CacheConfig(clock=lambda: state.now)) as client:
35+
await client.list_tools() # fetch 1
36+
await client.list_tools() # fresh for 60s: served from the cache
37+
state.now += 60.0
38+
await client.list_tools() # the TTL ran out: fetch 2
39+
await client.list_tools(cache_mode="refresh") # skip the cache read: fetch 3
40+
print(f"4 calls, {state.fetches - start} fetches")

src/mcp-types/mcp_types/methods.py

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
from collections.abc import Mapping
1414
from functools import cache
1515
from types import MappingProxyType, UnionType
16-
from typing import Any, Final, TypeVar
16+
from typing import Any, Final, Literal, TypeVar, get_args
1717

1818
from pydantic import BaseModel, TypeAdapter
1919

@@ -23,9 +23,11 @@
2323
from mcp_types.version import KNOWN_PROTOCOL_VERSIONS
2424

2525
__all__ = [
26+
"CACHEABLE_METHODS",
2627
"CLIENT_NOTIFICATIONS",
2728
"CLIENT_REQUESTS",
2829
"CLIENT_RESULTS",
30+
"CacheableMethod",
2931
"MONOLITH_NOTIFICATIONS",
3032
"MONOLITH_REQUESTS",
3133
"MONOLITH_RESULTS",
@@ -404,6 +406,24 @@
404406
"""Monolith result model (or two-arm union) per request method."""
405407

406408

409+
CacheableMethod = Literal[
410+
"prompts/list",
411+
"resources/list",
412+
"resources/read",
413+
"resources/templates/list",
414+
"server/discover",
415+
"tools/list",
416+
]
417+
"""Methods whose results carry `ttlMs`/`cacheScope`; hand-written Literal, welded to `CACHEABLE_METHODS` by tests."""
418+
419+
CACHEABLE_METHODS: Final[frozenset[str]] = frozenset(
420+
method
421+
for method, row in MONOLITH_RESULTS.items()
422+
if any(issubclass(arm, types.CacheableResult) for arm in (get_args(row) if isinstance(row, UnionType) else (row,)))
423+
)
424+
"""Runtime mirror of `CacheableMethod`, derived from `MONOLITH_RESULTS`."""
425+
426+
407427
# --- Parse functions ---
408428

409429
# Envelope stubs merged into bodies for surface validation (surface classes are full frames).

src/mcp/client/__init__.py

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,28 @@
22

33
from mcp.client._input_required import InputRequiredRoundsExceededError
44
from mcp.client._transport import Transport
5+
from mcp.client.caching import (
6+
CacheConfig,
7+
CacheEntry,
8+
CacheKey,
9+
CacheMode,
10+
InMemoryResponseCacheStore,
11+
ResponseCacheStore,
12+
)
513
from mcp.client.client import Client
614
from mcp.client.context import ClientRequestContext
715
from mcp.client.session import ClientSession
816

9-
__all__ = ["Client", "ClientRequestContext", "ClientSession", "InputRequiredRoundsExceededError", "Transport"]
17+
__all__ = [
18+
"CacheConfig",
19+
"CacheEntry",
20+
"CacheKey",
21+
"CacheMode",
22+
"Client",
23+
"ClientRequestContext",
24+
"ClientSession",
25+
"InMemoryResponseCacheStore",
26+
"InputRequiredRoundsExceededError",
27+
"ResponseCacheStore",
28+
"Transport",
29+
]

0 commit comments

Comments
 (0)