Skip to content

Commit 16935d2

Browse files
committed
Add cache_hints constructor map for SEP-2549 caching hints
Server and MCPServer take cache_hints={method: CacheHint(...)} to set ttlMs/cacheScope on the six cacheable results server-wide. The runner fills the typed result after the handler returns, so fields a handler sets explicitly win, per field (via model_fields_set), and the existing serialize sieve keeps pre-2026 wires clean. Keys are typed as the CacheableMethod Literal so editors autocomplete them and flag typos; runtime validation still rejects bad keys and values at construction for untyped callers. Part of #2899.
1 parent e942d00 commit 16935d2

14 files changed

Lines changed: 472 additions & 4 deletions

File tree

docs/advanced/caching.md

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
# Caching hints
2+
3+
Every result a server returns for `tools/list`, `prompts/list`, `resources/list`, `resources/templates/list`, `resources/read` and `server/discover` carries two fields on the 2026-07-28 protocol: `ttlMs`, how many milliseconds a client may treat the result as fresh, and `cacheScope`, whether a cached result may be shared across users (`"public"`) or belongs to one authorization context (`"private"`).
4+
5+
The server doesn't cache anything. The fields are a *declaration*: "this tool list is the same for everyone and won't change for a minute." A client (or a gateway in front of you) may then skip the round trip. Honoring the hints is the client's choice; emitting them is the server's job, and the SDK does it for you.
6+
7+
Out of the box every result says `ttlMs: 0, cacheScope: "private"` — immediately stale, never shared. That is always safe and always conformant. If your lists really are stable and identical for all callers, say so at construction:
8+
9+
```python title="server.py" hl_lines="5-8"
10+
--8<-- "docs_src/caching/tutorial001.py"
11+
```
12+
13+
* The map is keyed by **method name** — the six cacheable methods are the only legal keys. The parameter is typed `Mapping[CacheableMethod, CacheHint]`, so your editor autocompletes the keys and flags a typo before you run; anything that slips past the type checker raises at construction.
14+
* A method you don't mention keeps the defaults. The map is a set of overrides, not a manifest.
15+
* `CacheHint(ttl_ms=5_000)` left `scope` unset, so it stays `"private"`: five seconds of freshness, per caller. Scope and TTL are independent decisions.
16+
* `"server/discover"` is a legal key too — the handshake result is cacheable like any list.
17+
18+
!!! warning
19+
`cacheScope: "public"` means *anyone* may be served your cached response — a shared
20+
gateway will happily hand one user's result to another, even when the request was
21+
authenticated. Mark a result `"public"` only when it is identical for every caller, and
22+
never use `cacheScope` as access control: it is a label, not a lock.
23+
24+
## Per-handler override
25+
26+
On the low-level `Server`, handlers build their results by hand — and `ttl_ms` / `cache_scope` are just fields on the result models. A handler that sets them explicitly always wins over the constructor map, field by field:
27+
28+
```python title="server.py" hl_lines="11 17"
29+
--8<-- "docs_src/caching/tutorial002.py"
30+
```
31+
32+
The handler said `ttl_ms=1_000` and nothing about scope. On the wire: `ttlMs: 1000` (the handler's, not the map's `60_000`) and `cacheScope: "public"` (the map's — the handler left it unset). Explicit beats configured, configured beats default — per field, so a handler can pin one field and leave the other to the server-wide policy.
33+
34+
This is also the escape hatch for dynamics the constructor can't know: a handler that filters `resources/read` per user can return `cache_scope="private"` for one URI from an otherwise-public server.
35+
36+
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.
37+
38+
## Older clients
39+
40+
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.
41+
42+
## Recap
43+
44+
* Six methods carry `ttlMs`/`cacheScope`; the SDK defaults them to `0`/`"private"` — stale and unshared, always safe.
45+
* `cache_hints={method: CacheHint(...)}` at construction (both `MCPServer` and `Server`) sets server-wide values per method.
46+
* A handler that sets the fields on its result overrides the map, per field.
47+
* `"public"` is a promise that the result is identical for every caller. It is not access control.

docs/migration.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1554,7 +1554,7 @@ The implementation is responsible for validating the assertion per RFC 7523 §3
15541554

15551555
### 2025-11-25 and 2026-07-28 protocol fields modeled
15561556

1557-
`mcp_types` models the 2025-11-25 and 2026-07-28 protocol fields (e.g. `resultType`, `ttlMs`/`cacheScope` on cacheable results, `inputResponses`/`requestState` on retried requests), so inbound payloads carrying these keys parse into typed fields and round-trip. `ttlMs`/`cacheScope` default to `0`/`"private"` (immediately stale, not shared-cacheable); `resultType` defaults to `"complete"` on concrete results (`None` on `EmptyResult`); the server strips all of them from the wire at pre-2026 versions.
1557+
`mcp_types` models the 2025-11-25 and 2026-07-28 protocol fields (e.g. `resultType`, `ttlMs`/`cacheScope` on cacheable results, `inputResponses`/`requestState` on retried requests), so inbound payloads carrying these keys parse into typed fields and round-trip. `ttlMs`/`cacheScope` default to `0`/`"private"` (immediately stale, not shared-cacheable); `resultType` defaults to `"complete"` on concrete results (`None` on `EmptyResult`); the server strips all of them from the wire at pre-2026 versions. Servers set per-method values with `cache_hints={method: CacheHint(...)}` on the `Server`/`MCPServer` constructor — see [Caching hints](advanced/caching.md).
15581558

15591559
### `streamable_http_app()` available on lowlevel Server
15601560

docs_src/caching/__init__.py

Whitespace-only changes.

docs_src/caching/tutorial001.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
from mcp.server import CacheHint, MCPServer
2+
3+
mcp = MCPServer(
4+
"Weather",
5+
cache_hints={
6+
"tools/list": CacheHint(ttl_ms=60_000, scope="public"),
7+
"resources/read": CacheHint(ttl_ms=5_000),
8+
},
9+
)
10+
11+
12+
@mcp.tool()
13+
def forecast(city: str) -> str:
14+
return f"Sunny in {city}"
15+
16+
17+
@mcp.resource("config://units")
18+
def units() -> str:
19+
return "metric"

docs_src/caching/tutorial002.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
from typing import Any
2+
3+
from mcp_types import ListToolsResult, PaginatedRequestParams, Tool
4+
5+
from mcp.server import CacheHint, Server, ServerRequestContext
6+
7+
TOOLS = [Tool(name="forecast", input_schema={"type": "object"})]
8+
9+
10+
async def list_tools(ctx: ServerRequestContext[Any], params: PaginatedRequestParams | None) -> ListToolsResult:
11+
return ListToolsResult(tools=TOOLS, ttl_ms=1_000)
12+
13+
14+
server = Server(
15+
"Weather",
16+
on_list_tools=list_tools,
17+
cache_hints={"tools/list": CacheHint(ttl_ms=60_000, scope="public")},
18+
)

mkdocs.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ nav:
4242
- The low-level Server: advanced/low-level-server.md
4343
- URI templates: advanced/uri-templates.md
4444
- Pagination: advanced/pagination.md
45+
- Caching hints: advanced/caching.md
4546
- Middleware: advanced/middleware.md
4647
- OpenTelemetry: advanced/opentelemetry.md
4748
- Authorization: advanced/authorization.md

src/mcp/server/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1+
from .caching import CacheHint
12
from .context import ServerRequestContext
23
from .lowlevel import NotificationOptions, Server
34
from .mcpserver import MCPServer
45
from .models import InitializationOptions
56

6-
__all__ = ["Server", "ServerRequestContext", "MCPServer", "NotificationOptions", "InitializationOptions"]
7+
__all__ = ["CacheHint", "Server", "ServerRequestContext", "MCPServer", "NotificationOptions", "InitializationOptions"]

src/mcp/server/caching.py

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
"""Server-side caching hints (SEP-2549, protocol revision 2026-07-28).
2+
3+
Results for the cacheable methods carry `ttlMs`/`cacheScope` freshness hints.
4+
A handler sets them by returning a result with explicit `ttl_ms`/`cache_scope`
5+
values; `Server(cache_hints={method: CacheHint(...)})` fills them for handlers
6+
that don't. Fields the handler set win, per field, so a server-wide hint never
7+
overrides a handler's explicit choice.
8+
"""
9+
10+
from __future__ import annotations
11+
12+
from collections.abc import Mapping
13+
from dataclasses import dataclass
14+
from typing import Any, Final, Literal, TypeVar, get_args
15+
16+
import mcp_types as types
17+
18+
__all__ = ["CACHEABLE_METHODS", "CacheHint", "CacheableMethod", "apply_cache_hint", "validate_cache_hints"]
19+
20+
CacheableMethod = Literal[
21+
"prompts/list",
22+
"resources/list",
23+
"resources/read",
24+
"resources/templates/list",
25+
"server/discover",
26+
"tools/list",
27+
]
28+
"""The methods whose results carry `ttlMs`/`cacheScope`. Closed set: the spec
29+
defines caching hints on exactly these six (tests pin it to which result models
30+
mix in `CacheableResult`)."""
31+
32+
CACHEABLE_METHODS: Final[frozenset[str]] = frozenset(get_args(CacheableMethod))
33+
"""Runtime mirror of `CacheableMethod`, for callers the type checker can't see."""
34+
35+
36+
@dataclass(frozen=True, slots=True)
37+
class CacheHint:
38+
"""Freshness hint for one cacheable method's results.
39+
40+
`ttl_ms` is how long, in milliseconds, a client may consider the result
41+
fresh (`0` means immediately stale). `scope` is whether a cached result may
42+
be shared across authorization contexts (`"public"`) or only reused within
43+
the one that produced it (`"private"`).
44+
"""
45+
46+
ttl_ms: int = 0
47+
scope: Literal["public", "private"] = "private"
48+
49+
def __post_init__(self) -> None:
50+
if self.ttl_ms < 0:
51+
raise ValueError(f"ttl_ms must be >= 0, got {self.ttl_ms}")
52+
if self.scope not in ("public", "private"):
53+
raise ValueError(f"scope must be 'public' or 'private', got {self.scope!r}")
54+
55+
56+
CacheableResultT = TypeVar("CacheableResultT", bound=types.CacheableResult)
57+
58+
59+
def apply_cache_hint(result: CacheableResultT, hint: CacheHint) -> CacheableResultT:
60+
"""Fill `ttl_ms`/`cache_scope` on `result` from `hint`.
61+
62+
Per-field: a field the handler set explicitly - even to its default value,
63+
tracked via `model_fields_set` - is left alone; only unset fields take the
64+
hint. A handler constructing results with `model_construct` bypasses that
65+
tracking and is treated as having set nothing.
66+
"""
67+
update: dict[str, int | str] = {}
68+
if "ttl_ms" not in result.model_fields_set:
69+
update["ttl_ms"] = hint.ttl_ms
70+
if "cache_scope" not in result.model_fields_set:
71+
update["cache_scope"] = hint.scope
72+
return result.model_copy(update=update) if update else result
73+
74+
75+
def validate_cache_hints(cache_hints: Mapping[Any, Any] | None) -> dict[str, CacheHint]:
76+
"""Validate a `cache_hints` constructor argument into a plain dict.
77+
78+
The `Server`/`MCPServer` signatures already close the key set and value
79+
type for type-checked callers; this runtime gate is deliberately loose in
80+
its parameter so it covers everyone else (e.g. a map deserialized from
81+
config) - a bad entry fails at construction, not on the first request to
82+
that method.
83+
84+
Raises:
85+
ValueError: If a key is not a cacheable method.
86+
TypeError: If a value is not a `CacheHint`.
87+
"""
88+
if cache_hints is None:
89+
return {}
90+
unknown = sorted(method for method in cache_hints if method not in CACHEABLE_METHODS)
91+
if unknown:
92+
raise ValueError(f"cache_hints keys must be cacheable methods (see CacheableMethod); got: {', '.join(unknown)}")
93+
validated: dict[str, CacheHint] = {}
94+
for method, hint in cache_hints.items():
95+
if not isinstance(hint, CacheHint):
96+
raise TypeError(f"cache_hints[{method!r}] must be a CacheHint, got {type(hint).__name__}")
97+
validated[method] = hint
98+
return validated

src/mcp/server/lowlevel/server.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ async def main():
3838

3939
import logging
4040
import warnings
41-
from collections.abc import AsyncIterator, Awaitable, Callable
41+
from collections.abc import AsyncIterator, Awaitable, Callable, Mapping
4242
from contextlib import AbstractAsyncContextManager, asynccontextmanager
4343
from dataclasses import dataclass
4444
from importlib.metadata import version as importlib_version
@@ -59,6 +59,7 @@ async def main():
5959
from mcp.server.auth.provider import OAuthAuthorizationServerProvider, TokenVerifier
6060
from mcp.server.auth.routes import build_resource_metadata_url, create_auth_routes, create_protected_resource_routes
6161
from mcp.server.auth.settings import AuthSettings
62+
from mcp.server.caching import CacheableMethod, CacheHint, validate_cache_hints
6263
from mcp.server.context import HandlerResult, ServerMiddleware, ServerRequestContext
6364
from mcp.server.models import InitializationOptions
6465
from mcp.server.runner import serve_loop
@@ -140,6 +141,7 @@ def __init__(
140141
instructions: str | None = None,
141142
website_url: str | None = None,
142143
icons: list[types.Icon] | None = None,
144+
cache_hints: Mapping[CacheableMethod, CacheHint] | None = None,
143145
lifespan: Callable[
144146
[Server[LifespanResultT]],
145147
AbstractAsyncContextManager[LifespanResultT],
@@ -222,6 +224,7 @@ def __init__(
222224
instructions: str | None = None,
223225
website_url: str | None = None,
224226
icons: list[types.Icon] | None = None,
227+
cache_hints: Mapping[CacheableMethod, CacheHint] | None = None,
225228
lifespan: Callable[
226229
[Server[LifespanResultT]],
227230
AbstractAsyncContextManager[LifespanResultT],
@@ -313,6 +316,7 @@ def __init__(
313316
instructions: str | None = None,
314317
website_url: str | None = None,
315318
icons: list[types.Icon] | None = None,
319+
cache_hints: Mapping[CacheableMethod, CacheHint] | None = None,
316320
lifespan: Callable[
317321
[Server[LifespanResultT]],
318322
AbstractAsyncContextManager[LifespanResultT],
@@ -420,6 +424,9 @@ def __init__(
420424
self.instructions = instructions
421425
self.website_url = website_url
422426
self.icons = icons
427+
# Per-method `ttl_ms`/`cache_scope` fills, applied by `ServerRunner`
428+
# after the handler returns; fields the handler set explicitly win.
429+
self.cache_hints: dict[str, CacheHint] = validate_cache_hints(cache_hints)
423430
self.lifespan = lifespan
424431
self._request_handlers: dict[str, HandlerEntry[LifespanResultT]] = {}
425432
self._notification_handlers: dict[str, HandlerEntry[LifespanResultT]] = {}

src/mcp/server/mcpserver/server.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
import base64
66
import inspect
7-
from collections.abc import AsyncIterator, Awaitable, Callable, Iterable
7+
from collections.abc import AsyncIterator, Awaitable, Callable, Iterable, Mapping
88
from contextlib import AbstractAsyncContextManager, asynccontextmanager
99
from typing import Any, Generic, Literal, TypeVar, overload
1010

@@ -54,6 +54,7 @@
5454
from mcp.server.auth.middleware.bearer_auth import BearerAuthBackend, RequireAuthMiddleware
5555
from mcp.server.auth.provider import OAuthAuthorizationServerProvider, ProviderTokenVerifier, TokenVerifier
5656
from mcp.server.auth.settings import AuthSettings
57+
from mcp.server.caching import CacheableMethod, CacheHint
5758
from mcp.server.context import ServerRequestContext
5859
from mcp.server.lowlevel.helper_types import ReadResourceContents
5960
from mcp.server.lowlevel.server import LifespanResultT, Server
@@ -157,6 +158,7 @@ def __init__(
157158
lifespan: Callable[[MCPServer[LifespanResultT]], AbstractAsyncContextManager[LifespanResultT]] | None = None,
158159
auth: AuthSettings | None = None,
159160
resource_security: ResourceSecurity = DEFAULT_RESOURCE_SECURITY,
161+
cache_hints: Mapping[CacheableMethod, CacheHint] | None = None,
160162
):
161163
self._resource_security = resource_security
162164
self.settings = Settings(
@@ -184,6 +186,7 @@ def __init__(
184186
website_url=website_url,
185187
icons=icons,
186188
version=version,
189+
cache_hints=cache_hints,
187190
on_list_tools=self._handle_list_tools,
188191
on_call_tool=self._handle_call_tool,
189192
on_list_resources=self._handle_list_resources,

0 commit comments

Comments
 (0)