From d8d86ddf34f6d194aa3d29f82beba4bfca1f85bb Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Sun, 28 Jun 2026 12:06:43 +0000 Subject: [PATCH 1/2] 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. --- docs/advanced/caching.md | 47 +++++++ docs/migration.md | 2 +- docs_src/caching/__init__.py | 0 docs_src/caching/tutorial001.py | 19 +++ docs_src/caching/tutorial002.py | 18 +++ mkdocs.yml | 1 + src/mcp/server/__init__.py | 3 +- src/mcp/server/caching.py | 98 +++++++++++++++ src/mcp/server/lowlevel/server.py | 9 +- src/mcp/server/mcpserver/server.py | 5 +- src/mcp/server/runner.py | 8 ++ tests/docs_src/test_caching.py | 55 +++++++++ tests/server/test_caching.py | 190 +++++++++++++++++++++++++++++ tests/server/test_runner.py | 21 ++++ 14 files changed, 472 insertions(+), 4 deletions(-) create mode 100644 docs/advanced/caching.md create mode 100644 docs_src/caching/__init__.py create mode 100644 docs_src/caching/tutorial001.py create mode 100644 docs_src/caching/tutorial002.py create mode 100644 src/mcp/server/caching.py create mode 100644 tests/docs_src/test_caching.py create mode 100644 tests/server/test_caching.py diff --git a/docs/advanced/caching.md b/docs/advanced/caching.md new file mode 100644 index 0000000000..040422b55e --- /dev/null +++ b/docs/advanced/caching.md @@ -0,0 +1,47 @@ +# Caching hints + +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"`). + +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. + +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: + +```python title="server.py" hl_lines="5-8" +--8<-- "docs_src/caching/tutorial001.py" +``` + +* 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. +* A method you don't mention keeps the defaults. The map is a set of overrides, not a manifest. +* `CacheHint(ttl_ms=5_000)` left `scope` unset, so it stays `"private"`: five seconds of freshness, per caller. Scope and TTL are independent decisions. +* `"server/discover"` is a legal key too — the handshake result is cacheable like any list. + +!!! warning + `cacheScope: "public"` means *anyone* may be served your cached response — a shared + gateway will happily hand one user's result to another, even when the request was + authenticated. Mark a result `"public"` only when it is identical for every caller, and + never use `cacheScope` as access control: it is a label, not a lock. + +## Per-handler override + +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: + +```python title="server.py" hl_lines="11 17" +--8<-- "docs_src/caching/tutorial002.py" +``` + +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. + +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. + +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. + +## Older clients + +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. + +## Recap + +* Six methods carry `ttlMs`/`cacheScope`; the SDK defaults them to `0`/`"private"` — stale and unshared, always safe. +* `cache_hints={method: CacheHint(...)}` at construction (both `MCPServer` and `Server`) sets server-wide values per method. +* A handler that sets the fields on its result overrides the map, per field. +* `"public"` is a promise that the result is identical for every caller. It is not access control. diff --git a/docs/migration.md b/docs/migration.md index 79c15d91f6..a0609372db 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -1598,7 +1598,7 @@ The implementation is responsible for validating the assertion per RFC 7523 §3 ### 2025-11-25 and 2026-07-28 protocol fields modeled -`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. +`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). ### `streamable_http_app()` available on lowlevel Server diff --git a/docs_src/caching/__init__.py b/docs_src/caching/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docs_src/caching/tutorial001.py b/docs_src/caching/tutorial001.py new file mode 100644 index 0000000000..73e686db9f --- /dev/null +++ b/docs_src/caching/tutorial001.py @@ -0,0 +1,19 @@ +from mcp.server import CacheHint, MCPServer + +mcp = MCPServer( + "Weather", + cache_hints={ + "tools/list": CacheHint(ttl_ms=60_000, scope="public"), + "resources/read": CacheHint(ttl_ms=5_000), + }, +) + + +@mcp.tool() +def forecast(city: str) -> str: + return f"Sunny in {city}" + + +@mcp.resource("config://units") +def units() -> str: + return "metric" diff --git a/docs_src/caching/tutorial002.py b/docs_src/caching/tutorial002.py new file mode 100644 index 0000000000..6bbfec9e27 --- /dev/null +++ b/docs_src/caching/tutorial002.py @@ -0,0 +1,18 @@ +from typing import Any + +from mcp_types import ListToolsResult, PaginatedRequestParams, Tool + +from mcp.server import CacheHint, Server, ServerRequestContext + +TOOLS = [Tool(name="forecast", input_schema={"type": "object"})] + + +async def list_tools(ctx: ServerRequestContext[Any], params: PaginatedRequestParams | None) -> ListToolsResult: + return ListToolsResult(tools=TOOLS, ttl_ms=1_000) + + +server = Server( + "Weather", + on_list_tools=list_tools, + cache_hints={"tools/list": CacheHint(ttl_ms=60_000, scope="public")}, +) diff --git a/mkdocs.yml b/mkdocs.yml index 7acee7d5de..83d3a268ae 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -43,6 +43,7 @@ nav: - The low-level Server: advanced/low-level-server.md - URI templates: advanced/uri-templates.md - Pagination: advanced/pagination.md + - Caching hints: advanced/caching.md - Middleware: advanced/middleware.md - Extensions: advanced/extensions.md - MCP Apps: advanced/apps.md diff --git a/src/mcp/server/__init__.py b/src/mcp/server/__init__.py index aab5c33f7d..5897e8aa8b 100644 --- a/src/mcp/server/__init__.py +++ b/src/mcp/server/__init__.py @@ -1,6 +1,7 @@ +from .caching import CacheHint from .context import ServerRequestContext from .lowlevel import NotificationOptions, Server from .mcpserver import MCPServer from .models import InitializationOptions -__all__ = ["Server", "ServerRequestContext", "MCPServer", "NotificationOptions", "InitializationOptions"] +__all__ = ["CacheHint", "Server", "ServerRequestContext", "MCPServer", "NotificationOptions", "InitializationOptions"] diff --git a/src/mcp/server/caching.py b/src/mcp/server/caching.py new file mode 100644 index 0000000000..a8a2a470c6 --- /dev/null +++ b/src/mcp/server/caching.py @@ -0,0 +1,98 @@ +"""Server-side caching hints (SEP-2549, protocol revision 2026-07-28). + +Results for the cacheable methods carry `ttlMs`/`cacheScope` freshness hints. +A handler sets them by returning a result with explicit `ttl_ms`/`cache_scope` +values; `Server(cache_hints={method: CacheHint(...)})` fills them for handlers +that don't. Fields the handler set win, per field, so a server-wide hint never +overrides a handler's explicit choice. +""" + +from __future__ import annotations + +from collections.abc import Mapping +from dataclasses import dataclass +from typing import Any, Final, Literal, TypeVar, get_args + +import mcp_types as types + +__all__ = ["CACHEABLE_METHODS", "CacheHint", "CacheableMethod", "apply_cache_hint", "validate_cache_hints"] + +CacheableMethod = Literal[ + "prompts/list", + "resources/list", + "resources/read", + "resources/templates/list", + "server/discover", + "tools/list", +] +"""The methods whose results carry `ttlMs`/`cacheScope`. Closed set: the spec +defines caching hints on exactly these six (tests pin it to which result models +mix in `CacheableResult`).""" + +CACHEABLE_METHODS: Final[frozenset[str]] = frozenset(get_args(CacheableMethod)) +"""Runtime mirror of `CacheableMethod`, for callers the type checker can't see.""" + + +@dataclass(frozen=True, slots=True) +class CacheHint: + """Freshness hint for one cacheable method's results. + + `ttl_ms` is how long, in milliseconds, a client may consider the result + fresh (`0` means immediately stale). `scope` is whether a cached result may + be shared across authorization contexts (`"public"`) or only reused within + the one that produced it (`"private"`). + """ + + ttl_ms: int = 0 + scope: Literal["public", "private"] = "private" + + def __post_init__(self) -> None: + if self.ttl_ms < 0: + raise ValueError(f"ttl_ms must be >= 0, got {self.ttl_ms}") + if self.scope not in ("public", "private"): + raise ValueError(f"scope must be 'public' or 'private', got {self.scope!r}") + + +CacheableResultT = TypeVar("CacheableResultT", bound=types.CacheableResult) + + +def apply_cache_hint(result: CacheableResultT, hint: CacheHint) -> CacheableResultT: + """Fill `ttl_ms`/`cache_scope` on `result` from `hint`. + + Per-field: a field the handler set explicitly - even to its default value, + tracked via `model_fields_set` - is left alone; only unset fields take the + hint. A handler constructing results with `model_construct` bypasses that + tracking and is treated as having set nothing. + """ + update: dict[str, int | str] = {} + if "ttl_ms" not in result.model_fields_set: + update["ttl_ms"] = hint.ttl_ms + if "cache_scope" not in result.model_fields_set: + update["cache_scope"] = hint.scope + return result.model_copy(update=update) if update else result + + +def validate_cache_hints(cache_hints: Mapping[Any, Any] | None) -> dict[str, CacheHint]: + """Validate a `cache_hints` constructor argument into a plain dict. + + The `Server`/`MCPServer` signatures already close the key set and value + type for type-checked callers; this runtime gate is deliberately loose in + its parameter so it covers everyone else (e.g. a map deserialized from + config) - a bad entry fails at construction, not on the first request to + that method. + + Raises: + ValueError: If a key is not a cacheable method. + TypeError: If a value is not a `CacheHint`. + """ + if cache_hints is None: + return {} + unknown = sorted(method for method in cache_hints if method not in CACHEABLE_METHODS) + if unknown: + raise ValueError(f"cache_hints keys must be cacheable methods (see CacheableMethod); got: {', '.join(unknown)}") + validated: dict[str, CacheHint] = {} + for method, hint in cache_hints.items(): + if not isinstance(hint, CacheHint): + raise TypeError(f"cache_hints[{method!r}] must be a CacheHint, got {type(hint).__name__}") + validated[method] = hint + return validated diff --git a/src/mcp/server/lowlevel/server.py b/src/mcp/server/lowlevel/server.py index 6f4d9f8124..97b5557e20 100644 --- a/src/mcp/server/lowlevel/server.py +++ b/src/mcp/server/lowlevel/server.py @@ -38,7 +38,7 @@ async def main(): import logging import warnings -from collections.abc import AsyncIterator, Awaitable, Callable +from collections.abc import AsyncIterator, Awaitable, Callable, Mapping from contextlib import AbstractAsyncContextManager, asynccontextmanager from dataclasses import dataclass from importlib.metadata import version as importlib_version @@ -59,6 +59,7 @@ async def main(): from mcp.server.auth.provider import OAuthAuthorizationServerProvider, TokenVerifier from mcp.server.auth.routes import build_resource_metadata_url, create_auth_routes, create_protected_resource_routes from mcp.server.auth.settings import AuthSettings +from mcp.server.caching import CacheableMethod, CacheHint, validate_cache_hints from mcp.server.context import HandlerResult, ServerMiddleware, ServerRequestContext from mcp.server.models import InitializationOptions from mcp.server.runner import serve_loop @@ -140,6 +141,7 @@ def __init__( instructions: str | None = None, website_url: str | None = None, icons: list[types.Icon] | None = None, + cache_hints: Mapping[CacheableMethod, CacheHint] | None = None, lifespan: Callable[ [Server[LifespanResultT]], AbstractAsyncContextManager[LifespanResultT], @@ -222,6 +224,7 @@ def __init__( instructions: str | None = None, website_url: str | None = None, icons: list[types.Icon] | None = None, + cache_hints: Mapping[CacheableMethod, CacheHint] | None = None, lifespan: Callable[ [Server[LifespanResultT]], AbstractAsyncContextManager[LifespanResultT], @@ -313,6 +316,7 @@ def __init__( instructions: str | None = None, website_url: str | None = None, icons: list[types.Icon] | None = None, + cache_hints: Mapping[CacheableMethod, CacheHint] | None = None, lifespan: Callable[ [Server[LifespanResultT]], AbstractAsyncContextManager[LifespanResultT], @@ -420,6 +424,9 @@ def __init__( self.instructions = instructions self.website_url = website_url self.icons = icons + # Per-method `ttl_ms`/`cache_scope` fills, applied by `ServerRunner` + # after the handler returns; fields the handler set explicitly win. + self.cache_hints: dict[str, CacheHint] = validate_cache_hints(cache_hints) self.lifespan = lifespan self._request_handlers: dict[str, HandlerEntry[LifespanResultT]] = {} self._notification_handlers: dict[str, HandlerEntry[LifespanResultT]] = {} diff --git a/src/mcp/server/mcpserver/server.py b/src/mcp/server/mcpserver/server.py index 33348c0838..888eae6541 100644 --- a/src/mcp/server/mcpserver/server.py +++ b/src/mcp/server/mcpserver/server.py @@ -4,7 +4,7 @@ import base64 import inspect -from collections.abc import AsyncIterator, Awaitable, Callable, Iterable, Sequence +from collections.abc import AsyncIterator, Awaitable, Callable, Iterable, Mapping, Sequence from contextlib import AbstractAsyncContextManager, asynccontextmanager from typing import Any, Generic, Literal, TypeVar, overload @@ -58,6 +58,7 @@ from mcp.server.auth.middleware.bearer_auth import BearerAuthBackend, RequireAuthMiddleware from mcp.server.auth.provider import OAuthAuthorizationServerProvider, ProviderTokenVerifier, TokenVerifier from mcp.server.auth.settings import AuthSettings +from mcp.server.caching import CacheableMethod, CacheHint from mcp.server.context import HandlerResult, ServerRequestContext from mcp.server.extension import ( Extension, @@ -169,6 +170,7 @@ def __init__( lifespan: Callable[[MCPServer[LifespanResultT]], AbstractAsyncContextManager[LifespanResultT]] | None = None, auth: AuthSettings | None = None, resource_security: ResourceSecurity = DEFAULT_RESOURCE_SECURITY, + cache_hints: Mapping[CacheableMethod, CacheHint] | None = None, ): self._resource_security = resource_security self.settings = Settings( @@ -196,6 +198,7 @@ def __init__( website_url=website_url, icons=icons, version=version, + cache_hints=cache_hints, on_list_tools=self._handle_list_tools, on_call_tool=self._handle_call_tool, on_list_resources=self._handle_list_resources, diff --git a/src/mcp/server/runner.py b/src/mcp/server/runner.py index 0b57c3e5c2..4c25a8a5bc 100644 --- a/src/mcp/server/runner.py +++ b/src/mcp/server/runner.py @@ -28,6 +28,7 @@ INVALID_PARAMS, METHOD_NOT_FOUND, PROTOCOL_VERSION_META_KEY, + CacheableResult, ErrorData, Implementation, InitializeRequestParams, @@ -40,6 +41,7 @@ from pydantic import BaseModel, ValidationError from typing_extensions import TypeVar +from mcp.server.caching import apply_cache_hint from mcp.server.connection import Connection from mcp.server.context import CallNext, HandlerResult, ServerMiddleware, ServerRequestContext from mcp.server.models import InitializationOptions @@ -196,6 +198,12 @@ async def _inner(ctx: ServerRequestContext[LifespanT, Any]) -> HandlerResult: if isinstance(result, ErrorData): # Raise inside the chain so middleware observes the failure. raise MCPError.from_error_data(result) + # Fill cache hints on the typed result, before the serialize sieve + # decides whether the negotiated version carries the fields at all. + # `input_required` interim results are not `CacheableResult` models, + # so the MRTR carve-out (no hints on them) holds by shape. + if isinstance(result, CacheableResult) and (hint := self.server.cache_hints.get(method)) is not None: + result = apply_cache_hint(result, hint) # Dump and serialize inside the chain so the OpenTelemetry span (the # outermost middleware) records a failing handler return shape too. return self._serialize(method, version, result) diff --git a/tests/docs_src/test_caching.py b/tests/docs_src/test_caching.py new file mode 100644 index 0000000000..165f143bfe --- /dev/null +++ b/tests/docs_src/test_caching.py @@ -0,0 +1,55 @@ +"""`docs/advanced/caching.md`: every claim the page makes, proved against the real SDK.""" + +from typing import Any, cast + +import pytest +from inline_snapshot import snapshot + +from docs_src.caching import tutorial001, tutorial002 +from mcp import Client +from mcp.server import CacheHint, MCPServer + +# 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" diff --git a/tests/server/test_caching.py b/tests/server/test_caching.py new file mode 100644 index 0000000000..46701d6599 --- /dev/null +++ b/tests/server/test_caching.py @@ -0,0 +1,190 @@ +"""`mcp.server.caching`: `CacheHint` validation, per-field fills, and the +`cache_hints` constructor map reaching the wire on both server tiers.""" + +from types import UnionType +from typing import Any, cast, get_args + +import pytest +from inline_snapshot import snapshot +from mcp_types import ( + CacheableResult, + ListResourcesResult, + ListToolsResult, + PaginatedRequestParams, + Resource, + Tool, + methods, +) + +from mcp import Client +from mcp.server import CacheHint, MCPServer, Server, ServerRequestContext +from mcp.server.caching import CACHEABLE_METHODS, apply_cache_hint + +pytestmark = pytest.mark.anyio + + +def test_cacheable_methods_match_the_result_models() -> None: + """Spec-mandated set (SEP-2549): `CACHEABLE_METHODS` mirrors exactly the + methods whose monolith result models mix in `CacheableResult` - if the + schema gains or loses a cacheable result, this weld breaks.""" + derived: set[str] = set() + for method, model in methods.MONOLITH_RESULTS.items(): + arms = get_args(model) if isinstance(model, UnionType) else (model,) + if any(isinstance(arm, type) and issubclass(arm, CacheableResult) for arm in arms): + derived.add(method) + assert CACHEABLE_METHODS == derived + + +def test_cache_hint_defaults_match_the_conservative_model_defaults() -> None: + """SDK-defined: an unconfigured hint fills the same values the result models + already default to - immediately stale, not shared - so stamping it is + indistinguishable from not stamping at all.""" + hint = CacheHint() + model = ListToolsResult(tools=[]) + assert (hint.ttl_ms, hint.scope) == (model.ttl_ms, model.cache_scope) + + +def test_a_negative_ttl_is_rejected_at_hint_construction() -> None: + """Spec-mandated: servers MUST provide `ttlMs >= 0`, so a negative value + fails at `CacheHint` construction rather than reaching the wire.""" + with pytest.raises(ValueError) as exc: + CacheHint(ttl_ms=-1) + assert str(exc.value) == snapshot("ttl_ms must be >= 0, got -1") + + +def test_an_unknown_scope_is_rejected_at_hint_construction() -> None: + """Spec-mandated: `cacheScope` is a closed enum, enforced for untyped callers + the type checker cannot see.""" + with pytest.raises(ValueError) as exc: + CacheHint(scope=cast(Any, "shared")) + assert str(exc.value) == snapshot("scope must be 'public' or 'private', got 'shared'") + + +def test_apply_cache_hint_fills_only_the_fields_the_handler_left_unset() -> None: + """SDK-defined precedence, per field: the handler's explicit `ttl_ms` stays, + the unset `cache_scope` takes the hint's value.""" + result = ListToolsResult(tools=[], ttl_ms=10) + filled = apply_cache_hint(result, CacheHint(ttl_ms=60_000, scope="public")) + assert filled.ttl_ms == 10 + assert filled.cache_scope == "public" + + +def test_apply_cache_hint_never_overrides_explicit_fields_even_at_default_values() -> None: + """SDK-defined: an explicit `ttl_ms=0, cache_scope="private"` is a handler + decision, not an absence - the hint must not replace it (`model_fields_set` + distinguishes the two).""" + result = ListToolsResult(tools=[], ttl_ms=0, cache_scope="private") + assert apply_cache_hint(result, CacheHint(ttl_ms=60_000, scope="public")) is result + + +def test_a_non_cacheable_method_in_cache_hints_is_rejected_at_server_construction() -> None: + """SDK-defined: only the six cacheable methods take hints; a typo or a + non-cacheable method fails at `Server(...)` time, not silently at runtime.""" + with pytest.raises(ValueError) as exc: + Server("srv", cache_hints=cast(Any, {"tools/call": CacheHint()})) + assert str(exc.value) == snapshot( + "cache_hints keys must be cacheable methods (see CacheableMethod); got: tools/call" + ) + + +def test_a_non_cache_hint_value_is_rejected_at_server_construction() -> None: + """SDK-defined: a config-shaped value (a plain dict instead of a `CacheHint`) + fails at `Server(...)` time too - not with an `AttributeError` on the first + request to that method.""" + with pytest.raises(TypeError) as exc: + Server("srv", cache_hints=cast(Any, {"tools/list": {"ttl_ms": 60_000}})) + assert str(exc.value) == snapshot("cache_hints['tools/list'] must be a CacheHint, got dict") + + +async def test_server_cache_hints_reach_the_wire_for_a_bare_handler_result() -> None: + """SDK-defined: a lowlevel handler that never thinks about caching emits the + server-wide hint configured at construction.""" + hint = CacheHint(ttl_ms=60_000, scope="public") + + async def list_tools(ctx: ServerRequestContext[Any], params: PaginatedRequestParams | None) -> ListToolsResult: + return ListToolsResult(tools=[Tool(name="t", input_schema={"type": "object"})]) + + server = Server("srv", on_list_tools=list_tools, cache_hints={"tools/list": hint}) + async with Client(server) as client: + result = await client.list_tools() + assert result.ttl_ms == hint.ttl_ms + assert result.cache_scope == hint.scope + + +async def test_every_page_of_a_paginated_list_carries_the_configured_scope() -> None: + """Spec-mandated: the same `cacheScope` MUST apply to all pages of one list. + The map is keyed by method, not cursor, so a handler that leaves scope unset + gets the same scope on every page. (A handler that overrides the scope owns + that consistency itself - see `docs/advanced/caching.md`.)""" + names = [f"r-{n}" for n in range(4)] + + async def list_resources( + ctx: ServerRequestContext[Any], params: PaginatedRequestParams | None + ) -> ListResourcesResult: + start = 0 if params is None or params.cursor is None else int(params.cursor) + page = [Resource(uri=f"res://{name}", name=name) for name in names[start : start + 2]] + next_cursor = str(start + 2) if start + 2 < len(names) else None + return ListResourcesResult(resources=page, next_cursor=next_cursor) + + server = Server( + "srv", + on_list_resources=list_resources, + cache_hints={"resources/list": CacheHint(ttl_ms=30_000, scope="public")}, + ) + async with Client(server) as client: + first = await client.list_resources() + assert first.next_cursor is not None + second = await client.list_resources(cursor=first.next_cursor) + assert (first.cache_scope, second.cache_scope) == ("public", "public") + assert (first.ttl_ms, second.ttl_ms) == (30_000, 30_000) + + +async def test_the_default_discover_handler_takes_the_server_discover_hint() -> None: + """SDK-defined: the auto-derived `server/discover` result is stamped from the + map like any other cacheable result - no separate discover-specific knob.""" + server = Server("srv", cache_hints={"server/discover": CacheHint(ttl_ms=300_000, scope="public")}) + async with Client(server) as client: + discovered = await client.session.discover() + assert discovered.ttl_ms == 300_000 + assert discovered.cache_scope == "public" + + +async def test_mcpserver_cache_hints_cover_every_high_level_handler() -> None: + """SDK-defined: the `MCPServer` constructor map reaches all six cacheable + methods. Each method gets a distinct `ttl_ms` so a failure names the handler + that lost its hint.""" + mcp = MCPServer( + "demo", + cache_hints={ + "tools/list": CacheHint(ttl_ms=1_000, scope="public"), + "resources/list": CacheHint(ttl_ms=2_000, scope="public"), + "resources/templates/list": CacheHint(ttl_ms=3_000, scope="public"), + "prompts/list": CacheHint(ttl_ms=4_000, scope="public"), + "resources/read": CacheHint(ttl_ms=5_000, scope="public"), + "server/discover": CacheHint(ttl_ms=6_000, scope="public"), + }, + ) + + @mcp.tool() + def add(a: int, b: int) -> int: + raise NotImplementedError + + @mcp.resource("config://app") + def config() -> str: + return "cfg" + + @mcp.resource("greeting://{name}") + def greeting(name: str) -> str: + raise NotImplementedError + + @mcp.prompt() + def hello() -> str: + raise NotImplementedError + + async with Client(mcp) as client: + assert (await client.list_tools()).ttl_ms == 1_000 + assert (await client.list_resources()).ttl_ms == 2_000 + assert (await client.list_resource_templates()).ttl_ms == 3_000 + assert (await client.list_prompts()).ttl_ms == 4_000 + assert (await client.read_resource("config://app")).ttl_ms == 5_000 + assert (await client.session.discover()).ttl_ms == 6_000 diff --git a/tests/server/test_runner.py b/tests/server/test_runner.py index ed9662f08d..9200158459 100644 --- a/tests/server/test_runner.py +++ b/tests/server/test_runner.py @@ -36,6 +36,7 @@ from mcp_types.version import LATEST_HANDSHAKE_VERSION, LATEST_MODERN_VERSION, OLDEST_SUPPORTED_VERSION import mcp.server.runner +from mcp.server.caching import CacheHint from mcp.server.connection import Connection from mcp.server.context import ServerRequestContext from mcp.server.lowlevel.server import NotificationOptions, Server @@ -861,6 +862,26 @@ async def list_tools(ctx: Ctx, params: PaginatedRequestParams | None) -> ListToo assert result == {"tools": [{"name": "t", "inputSchema": {"type": "object"}}]} +@pytest.mark.anyio +async def test_runner_outbound_sieve_drops_configured_cache_hints_at_a_pre_2026_version(): + """A `cache_hints` map fills the typed result before serialization, so the + same sieve that strips handler-set fields strips configured ones too - a + 2025 client never sees `ttlMs`/`cacheScope`.""" + + async def list_tools(ctx: Ctx, params: PaginatedRequestParams | None) -> ListToolsResult: + return ListToolsResult(tools=[Tool(name="t", input_schema={"type": "object"})]) + + server: SrvT = Server( + "test-server", + on_list_tools=list_tools, + cache_hints={"tools/list": CacheHint(ttl_ms=60_000, scope="public")}, + ) + async with connected_runner(server) as (client, runner): + assert runner.connection.protocol_version == "2025-11-25" + result = await client.send_raw_request("tools/list", None) + assert result == {"tools": [{"name": "t", "inputSchema": {"type": "object"}}]} + + @pytest.mark.anyio async def test_runner_server_direction_spec_method_routes_to_a_registered_handler(server: SrvT): """`roots/list` is a spec method but server-to-client only; on a server it From 20a2d64996ce0816dc9a551fcb42a7ba4b4aeef8 Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Mon, 29 Jun 2026 14:08:29 +0000 Subject: [PATCH 2/2] 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. --- docs/advanced/caching.md | 17 +++++++++++++++++ docs_src/caching/tutorial003.py | 15 +++++++++++++++ tests/docs_src/test_caching.py | 17 ++++++++++++++++- 3 files changed, 48 insertions(+), 1 deletion(-) create mode 100644 docs_src/caching/tutorial003.py diff --git a/docs/advanced/caching.md b/docs/advanced/caching.md index 040422b55e..f53a3096bf 100644 --- a/docs/advanced/caching.md +++ b/docs/advanced/caching.md @@ -35,6 +35,22 @@ This is also the escape hatch for dynamics the constructor can't know: a handler 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. +## What the client sees + +On the client, the hints arrive as plain fields on every cacheable result — `ttl_ms` and `cache_scope`, already parsed: + +```python title="client.py" hl_lines="15" +--8<-- "docs_src/caching/tutorial003.py" +``` + +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: + +* **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. +* **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. +* **Notifications beat TTL.** If the server sends `list_changed` while your copy is still fresh, the copy is stale now — re-fetch. + +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. + ## Older clients 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 * `cache_hints={method: CacheHint(...)}` at construction (both `MCPServer` and `Server`) sets server-wide values per method. * A handler that sets the fields on its result overrides the map, per field. * `"public"` is a promise that the result is identical for every caller. It is not access control. +* 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. diff --git a/docs_src/caching/tutorial003.py b/docs_src/caching/tutorial003.py new file mode 100644 index 0000000000..77ade546b7 --- /dev/null +++ b/docs_src/caching/tutorial003.py @@ -0,0 +1,15 @@ +from mcp import Client +from mcp.server import CacheHint, MCPServer + +mcp = MCPServer("Weather", cache_hints={"tools/list": CacheHint(ttl_ms=60_000, scope="public")}) + + +@mcp.tool() +def forecast(city: str) -> str: + return f"Sunny in {city}" + + +async def main() -> None: + async with Client(mcp) as client: + tools = await client.list_tools() + print(f"{len(tools.tools)} tools, fresh for {tools.ttl_ms / 1000:.0f}s, scope={tools.cache_scope}") diff --git a/tests/docs_src/test_caching.py b/tests/docs_src/test_caching.py index 165f143bfe..bc2feb9ac0 100644 --- a/tests/docs_src/test_caching.py +++ b/tests/docs_src/test_caching.py @@ -5,7 +5,7 @@ import pytest from inline_snapshot import snapshot -from docs_src.caching import tutorial001, tutorial002 +from docs_src.caching import tutorial001, tutorial002, tutorial003 from mcp import Client from mcp.server import CacheHint, MCPServer @@ -53,3 +53,18 @@ async def test_the_handler_value_wins_over_the_map_per_field() -> None: 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_reads_the_hints(capsys: pytest.CaptureFixture[str]) -> None: + """tutorial003: `main()` is the literal client program on the page - the hints + arrive as parsed fields on the result.""" + await tutorial003.main() + assert capsys.readouterr().out == "1 tools, fresh for 60s, scope=public\n" + + +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(tutorial003.mcp) as client: + tools = await client.list_tools() + assert "ttl_ms" in tools.model_fields_set