Add cache_hints constructor map for SEP-2549 caching hints (#3015)
This commit is contained in:
parent
c85836a081
commit
533c6a8226
15 changed files with 519 additions and 4 deletions
64
docs/advanced/caching.md
Normal file
64
docs/advanced/caching.md
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
# 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.
|
||||
|
||||
## 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.
|
||||
|
||||
## 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.
|
||||
* 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.
|
||||
|
|
@ -1600,7 +1600,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
|
||||
|
||||
|
|
|
|||
0
docs_src/caching/__init__.py
Normal file
0
docs_src/caching/__init__.py
Normal file
19
docs_src/caching/tutorial001.py
Normal file
19
docs_src/caching/tutorial001.py
Normal file
|
|
@ -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"
|
||||
18
docs_src/caching/tutorial002.py
Normal file
18
docs_src/caching/tutorial002.py
Normal file
|
|
@ -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")},
|
||||
)
|
||||
15
docs_src/caching/tutorial003.py
Normal file
15
docs_src/caching/tutorial003.py
Normal file
|
|
@ -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}")
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
98
src/mcp/server/caching.py
Normal file
98
src/mcp/server/caching.py
Normal file
|
|
@ -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
|
||||
|
|
@ -38,7 +38,7 @@ from __future__ import annotations
|
|||
|
||||
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 @@ from mcp.server.auth.middleware.bearer_auth import BearerAuthBackend, RequireAut
|
|||
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 @@ class Server(Generic[LifespanResultT]):
|
|||
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 @@ class Server(Generic[LifespanResultT]):
|
|||
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 @@ class Server(Generic[LifespanResultT]):
|
|||
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 @@ class Server(Generic[LifespanResultT]):
|
|||
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]] = {}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ from __future__ import annotations
|
|||
|
||||
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.auth_context import AuthContextMiddleware
|
|||
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 @@ class MCPServer(Generic[LifespanResultT]):
|
|||
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 @@ class MCPServer(Generic[LifespanResultT]):
|
|||
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,
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ from mcp_types import (
|
|||
INVALID_PARAMS,
|
||||
METHOD_NOT_FOUND,
|
||||
PROTOCOL_VERSION_META_KEY,
|
||||
CacheableResult,
|
||||
ErrorData,
|
||||
Implementation,
|
||||
InitializeRequestParams,
|
||||
|
|
@ -40,6 +41,7 @@ from mcp_types.version import HANDSHAKE_PROTOCOL_VERSIONS, LATEST_HANDSHAKE_VERS
|
|||
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 @@ class ServerRunner(Generic[LifespanT]):
|
|||
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)
|
||||
|
|
|
|||
70
tests/docs_src/test_caching.py
Normal file
70
tests/docs_src/test_caching.py
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
"""`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, tutorial003
|
||||
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"
|
||||
|
||||
|
||||
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
|
||||
190
tests/server/test_caching.py
Normal file
190
tests/server/test_caching.py
Normal file
|
|
@ -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
|
||||
|
|
@ -36,6 +36,7 @@ from mcp_types import (
|
|||
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 test_runner_outbound_sieve_drops_2026_only_result_keys_at_a_pre_2026_v
|
|||
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
|
||||
|
|
|
|||
Loading…
Reference in a new issue