diff --git a/docs/development/v4-notes/change-register.mdx b/docs/development/v4-notes/change-register.mdx index e8f6416e7..6476d96cd 100644 --- a/docs/development/v4-notes/change-register.mdx +++ b/docs/development/v4-notes/change-register.mdx @@ -283,6 +283,12 @@ On a `2026-07-28` connection the degradation error used to differ by feature: `c *Verify:* `tests/server/test_protocol_eras.py` (`test_elicit_sample_degradation_message_is_clear_on_modern`, now a real test), `server/context.py` (era gates). +### Server-level cache hints (SEP-2549) — New (opt-in feature) + +A FastMCP server can emit SEP-2549 freshness hints so a caching client (`fastmcp.Client(cache=...)`) may reuse a response without a wire round-trip. Two constructor params carry it: `FastMCP(cache_ttl=300, cache_scope="public")`, where `cache_ttl` is in seconds and `cache_scope` is `"public"` or `"private"` (default `"private"` when a TTL is set). The hint is uniform by construction — one server-level value applies to every SDK-cacheable method (`tools/list`, `prompts/list`, `resources/list`, `resources/templates/list`, `resources/read`, and `server/discover`) with no per-component surface and no aggregation. FastMCP does not hand-set the wire fields: it passes the hint through to the SDK low-level `Server(cache_hints=...)`, whose runner fills `ttlMs`/`cacheScope` on every cacheable result via `apply_cache_hint`, leaving any field a handler set explicitly untouched. `cache_ttl` must be positive, and a `cache_scope` without a `cache_ttl` is rejected at construction (a scope alone does not enable caching, since the client gates on the TTL's presence). Absent both params, no hint is emitted. Honoring is modern-only (the SDK client reads hints only at `2026-07-28`) and opt-in on the client, so a hinted server is inert unless the client passes `cache=`. + +*Verify:* `fastmcp_slim/fastmcp/server/caching.py` (`build_cache_hints`), `fastmcp_slim/fastmcp/server/server.py` (constructor params passed to `LowLevelServer(cache_hints=...)`), `tests/server/test_cache_hints.py` (unit validation + end-to-end interop with `fastmcp.Client(cache=True)`). + ### The xfail register — Known gap Roughly forty `xfail` markers across the test tree (concentrated in `tests/server/tasks/`, `tests/client/tasks/`, and `test_protocol_eras.py`) are the built-in beta tracker: each names the SDK gap it waits on. They are enumerated and mapped to sdk-feedback findings on the [Known Gaps](/development/v4-notes/known-gaps) page. diff --git a/docs/servers/server.mdx b/docs/servers/server.mdx index 65befc502..9f6374a5d 100644 --- a/docs/servers/server.mdx +++ b/docs/servers/server.mdx @@ -201,6 +201,14 @@ These parameters tune how the server processes requests and communicates with cl Automatically dereference `$ref` pointers in JSON schemas generated from complex Pydantic models. Most clients require flat schemas without `$ref`, so this should usually stay enabled + + + How long, in seconds, a client may treat this server's cacheable responses as fresh (SEP-2549). When set, the hint applies uniformly to `tools/list`, `prompts/list`, `resources/list`, `resources/templates/list`, and `resources/read`. Clients must opt into caching to honor it — see [Response caching](/clients/client#response-caching). Must be a positive integer + + + + Whether a cached response may be shared across authorization contexts (`"public"`) or reused only within the one that produced it (`"private"`, the default when a `cache_ttl` is set). Requires `cache_ttl` + ### Handlers and Storage @@ -222,6 +230,27 @@ These parameters provide custom handlers for MCP capabilities and persistent sto +## Response Caching + + + +A server whose listings and resource reads change slowly can tell clients how long they may reuse a response before fetching it again (SEP-2549). Set `cache_ttl` (seconds) on the server, and the hint is attached uniformly to every cacheable response — `tools/list`, `prompts/list`, `resources/list`, `resources/templates/list`, and `resources/read`. + +```python +from fastmcp import FastMCP + +mcp = FastMCP("Weather", cache_ttl=300, cache_scope="public") + +@mcp.tool +def forecast(city: str) -> str: + return f"Sunny in {city}" +``` + +`cache_scope` controls whether a cached response may be shared across authorization contexts (`"public"`) or reused only within the one that produced it (`"private"`, the default when a TTL is set). A `cache_scope` without a `cache_ttl` does not enable caching and raises at construction. + +The hint is inert on its own: a client only reuses a response if it opts into caching and negotiates the modern protocol. See [Response caching](/clients/client#response-caching) for the client side. + + ## Tag-Based Filtering diff --git a/fastmcp_slim/fastmcp/server/caching.py b/fastmcp_slim/fastmcp/server/caching.py new file mode 100644 index 000000000..a5ae1777f --- /dev/null +++ b/fastmcp_slim/fastmcp/server/caching.py @@ -0,0 +1,58 @@ +"""Server-level cache hints for FastMCP (SEP-2549). + +A FastMCP server opts every SDK-cacheable result it emits into client-side +caching by setting `cache_ttl` (seconds) and, optionally, `cache_scope` on the +`FastMCP` constructor. The hint is uniform by construction: one server-level +value applies to `tools/list`, `prompts/list`, `resources/list`, +`resources/templates/list`, `resources/read`, and `server/discover` alike — no +per-component surface and no aggregation. + +FastMCP does not hand-set the wire fields. It passes the hint through to the SDK +low-level `Server(cache_hints=...)`, whose runner fills `ttlMs`/`cacheScope` on +every cacheable result via `apply_cache_hint`, leaving any field a handler set +explicitly untouched. Honoring is modern-only and opt-in on the client: a hinted +server is inert unless the client passes `cache=` and negotiates `2026-07-28`. +""" + +from __future__ import annotations + +from typing import Literal, get_args + +from mcp.server.caching import CacheHint +from mcp_types.methods import CacheableMethod + +CacheScope = Literal["public", "private"] +"""Whether a cached result may be shared across authorization contexts +(`"public"`) or reused only within the one that produced it (`"private"`).""" + + +def build_cache_hints( + cache_ttl: int | None, + cache_scope: CacheScope | None, +) -> dict[CacheableMethod, CacheHint] | None: + """Build the per-method `CacheHint` map for the SDK low-level server. + + `cache_ttl` is in seconds and is converted to the wire's milliseconds. When + `cache_ttl` is `None` the server emits no hint, so its wire output is + identical to a server that never set one; a `cache_scope` given without a + `cache_ttl` is meaningless (the client gates caching on the presence of a + TTL) and is rejected rather than silently ignored. + + Returns `None` when no hint is set, or a map applying the same hint to every + SDK-cacheable method otherwise. + + Raises: + ValueError: If `cache_ttl` is not positive, or if `cache_scope` is set + without `cache_ttl`. + """ + if cache_ttl is None: + if cache_scope is not None: + raise ValueError( + "cache_scope requires cache_ttl; a scope without a TTL does not " + "enable caching" + ) + return None + if cache_ttl <= 0: + raise ValueError(f"cache_ttl must be a positive integer, got {cache_ttl}") + hint = CacheHint(ttl_ms=cache_ttl * 1000, scope=cache_scope or "private") + return dict.fromkeys(get_args(CacheableMethod), hint) diff --git a/fastmcp_slim/fastmcp/server/server.py b/fastmcp_slim/fastmcp/server/server.py index 7f300731a..aea64f111 100644 --- a/fastmcp_slim/fastmcp/server/server.py +++ b/fastmcp_slim/fastmcp/server/server.py @@ -59,6 +59,7 @@ from fastmcp.prompts.function_prompt import FunctionPrompt from fastmcp.resources.base import Resource, ResourceResult from fastmcp.resources.template import ResourceTemplate from fastmcp.server.auth import AuthCheck, AuthContext, AuthProvider, run_auth_checks +from fastmcp.server.caching import build_cache_hints from fastmcp.server.lifespan import Lifespan from fastmcp.server.low_level import LowLevelServer from fastmcp.server.middleware import CallNext, Middleware, MiddlewareContext @@ -329,6 +330,8 @@ class FastMCP( dereference_schemas: bool = True, strict_input_validation: bool | None = None, list_page_size: int | None = None, + cache_ttl: int | None = None, + cache_scope: Literal["public", "private"] | None = None, tasks: bool | None = None, session_state_store: AsyncKeyValue | None = None, sampling_handler: SamplingHandler | None = None, @@ -387,6 +390,11 @@ class FastMCP( raise ValueError("list_page_size must be a positive integer") self._list_page_size: int | None = list_page_size + # Server-level SEP-2549 cache hints, applied uniformly to every + # SDK-cacheable result by the low-level server's runner (raises on + # invalid ttl/scope). + cache_hints = build_cache_hints(cache_ttl, cache_scope) + # Handle Lifespan instances (they're callable) or regular lifespan functions if lifespan is not None: self._lifespan: LifespanCallable[LifespanResultT] = cast( @@ -415,6 +423,7 @@ class FastMCP( website_url=website_url, icons=icons, lifespan=_lifespan_proxy(fastmcp_server=self), + cache_hints=cache_hints, ) self.auth: AuthProvider | None = auth diff --git a/tests/server/test_cache_hints.py b/tests/server/test_cache_hints.py new file mode 100644 index 000000000..c477c1965 --- /dev/null +++ b/tests/server/test_cache_hints.py @@ -0,0 +1,219 @@ +"""Server-level cache hints (SEP-2549) on the FastMCP constructor. + +A FastMCP server opts every SDK-cacheable result it emits into client-side +caching with `cache_ttl` (seconds) and an optional `cache_scope`. The hint is +uniform by construction — one server-level value applies to `tools/list`, +`prompts/list`, `resources/list`, `resources/templates/list`, `resources/read`, +and `server/discover` alike. FastMCP passes the hint through to the SDK +low-level `Server(cache_hints=...)`, whose runner fills `ttlMs`/`cacheScope` on +every cacheable result; FastMCP never hand-sets the wire fields. + +The end-to-end tests drive a FastMCP server through a `fastmcp.Client(cache=True)` +negotiating `2026-07-28`, proving the server-emitted hint and the client cache +interoperate — the server half of the feature whose client half is exercised in +`tests/client/client/test_response_cache.py`. +""" + +from __future__ import annotations + +import pytest +from mcp_types.methods import CACHEABLE_METHODS + +from fastmcp import Client, FastMCP +from fastmcp.server.caching import build_cache_hints + + +class TestBuildCacheHints: + def test_none_when_no_hint_set(self): + assert build_cache_hints(None, None) is None + + def test_covers_every_cacheable_method(self): + hints = build_cache_hints(60, "public") + assert hints is not None + assert set(hints) == set(CACHEABLE_METHODS) + + def test_seconds_converted_to_ms(self): + hints = build_cache_hints(60, "public") + assert hints is not None + hint = hints["tools/list"] + assert hint.ttl_ms == 60000 + assert hint.scope == "public" + + def test_scope_defaults_to_private(self): + hints = build_cache_hints(30, None) + assert hints is not None + assert hints["tools/list"].scope == "private" + + @pytest.mark.parametrize("cache_ttl", [0, -1]) + def test_non_positive_ttl_rejected(self, cache_ttl): + with pytest.raises(ValueError, match="cache_ttl must be a positive integer"): + build_cache_hints(cache_ttl, None) + + @pytest.mark.parametrize("scope", ["public", "private"]) + def test_scope_without_ttl_rejected(self, scope): + with pytest.raises(ValueError, match="cache_scope requires cache_ttl"): + build_cache_hints(None, scope) + + +class TestConstructorValidation: + @pytest.mark.parametrize("cache_ttl", [0, -5]) + def test_non_positive_ttl_raises(self, cache_ttl): + with pytest.raises(ValueError, match="cache_ttl must be a positive integer"): + FastMCP("x", cache_ttl=cache_ttl) + + def test_scope_without_ttl_raises(self): + with pytest.raises(ValueError, match="cache_scope requires cache_ttl"): + FastMCP("x", cache_scope="public") + + def test_no_cache_params_is_valid(self): + # A server with no cache params constructs cleanly and emits no hint. + FastMCP("x") + + +class TestServerEmitsHints: + """A hinted server sets the wire fields the SDK client cache reads.""" + + async def test_tools_list_carries_hint(self): + mcp = FastMCP("x", cache_ttl=60, cache_scope="public") + + @mcp.tool + def add(a: int, b: int) -> int: + return a + b + + async with Client(mcp, mode="auto") as client: + result = await client.session.list_tools() + + assert result.ttl_ms == 60000 + assert result.cache_scope == "public" + + async def test_prompts_list_carries_hint(self): + mcp = FastMCP("x", cache_ttl=45, cache_scope="public") + + @mcp.prompt + def greet(name: str) -> str: + return f"Hello, {name}" + + async with Client(mcp, mode="auto") as client: + result = await client.session.list_prompts() + + assert result.ttl_ms == 45000 + assert result.cache_scope == "public" + + async def test_resources_list_and_read_carry_hint(self): + mcp = FastMCP("x", cache_ttl=120, cache_scope="public") + + @mcp.resource("data://config") + def config() -> str: + return "value" + + async with Client(mcp, mode="auto") as client: + listing = await client.session.list_resources() + read = await client.session.read_resource("data://config") + + assert listing.ttl_ms == 120000 + assert listing.cache_scope == "public" + assert read.ttl_ms == 120000 + assert read.cache_scope == "public" + + async def test_resource_templates_list_carries_hint(self): + mcp = FastMCP("x", cache_ttl=90) + + @mcp.resource("data://{key}/value") + def item(key: str) -> str: + return key + + async with Client(mcp, mode="auto") as client: + listing = await client.session.list_resource_templates() + + assert listing.ttl_ms == 90000 + + async def test_scope_defaults_to_private(self): + mcp = FastMCP("x", cache_ttl=30) + + @mcp.tool + def add(a: int, b: int) -> int: + return a + b + + async with Client(mcp, mode="auto") as client: + result = await client.session.list_tools() + + assert result.ttl_ms == 30000 + assert result.cache_scope == "private" + + +class TestEndToEndInterop: + """A FastMCP server + `fastmcp.Client(cache=True)`: a hinted listing serves + from the cache with no second wire request, an unhinted one does not.""" + + async def test_hinted_list_tools_served_from_cache(self): + mcp = FastMCP("x", cache_ttl=60, cache_scope="public") + + @mcp.tool + def add(a: int, b: int) -> int: + return a + b + + async with Client(mcp, mode="auto", cache=True) as client: + await client.list_tools() + + calls = {"n": 0} + original = client.session.list_tools + + async def spy(**kwargs): + calls["n"] += 1 + return await original(**kwargs) + + client.session.list_tools = spy # type: ignore[method-assign] + second = await client.list_tools() + + assert calls["n"] == 0 # served from cache, no second wire request + assert [t.name for t in second] == ["add"] + + async def test_unhinted_server_not_cached(self): + mcp = FastMCP("x") + + @mcp.tool + def add(a: int, b: int) -> int: + return a + b + + async with Client(mcp, mode="auto", cache=True) as client: + await client.list_tools() + + calls = {"n": 0} + original = client.session.list_tools + + async def spy(**kwargs): + calls["n"] += 1 + return await original(**kwargs) + + client.session.list_tools = spy # type: ignore[method-assign] + await client.list_tools() + + assert calls["n"] == 1 # nothing cached, a second wire request happens + + async def test_private_scope_respected(self): + mcp = FastMCP("x", cache_ttl=60, cache_scope="private") + + @mcp.tool + def add(a: int, b: int) -> int: + return a + b + + async with Client(mcp, mode="auto") as client: + result = await client.session.list_tools() + + assert result.ttl_ms == 60000 + assert result.cache_scope == "private" + + async def test_hinted_read_resource_carries_cacheable_fields(self): + """The server sets the wire fields the SDK client cache reads on a + `resources/read` result, so a cache-capable client can reuse it.""" + mcp = FastMCP("x", cache_ttl=120, cache_scope="public") + + @mcp.resource("data://config") + def config() -> str: + return "value" + + async with Client(mcp, mode="auto", cache=True) as client: + result = await client.session.read_resource("data://config") + + assert result.ttl_ms == 120000 + assert result.cache_scope == "public"