mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-09 07:09:11 +02:00
Add server-level cache hints (SEP-2549) (#4464)
This commit is contained in:
parent
3c43038860
commit
8ba5b89918
5 changed files with 321 additions and 0 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -201,6 +201,14 @@ These parameters tune how the server processes requests and communicates with cl
|
|||
<ParamField body="dereference_schemas" type="bool" default="True">
|
||||
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
|
||||
</ParamField>
|
||||
|
||||
<ParamField body="cache_ttl" type="int | None" default="None">
|
||||
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
|
||||
</ParamField>
|
||||
|
||||
<ParamField body="cache_scope" type='Literal["public", "private"] | None' default="None">
|
||||
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`
|
||||
</ParamField>
|
||||
</Card>
|
||||
|
||||
### Handlers and Storage
|
||||
|
|
@ -222,6 +230,27 @@ These parameters provide custom handlers for MCP capabilities and persistent sto
|
|||
</Card>
|
||||
|
||||
|
||||
## Response Caching
|
||||
|
||||
<VersionBadge version="4.0.0" />
|
||||
|
||||
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
|
||||
|
||||
<VersionBadge version="2.8.0" />
|
||||
|
|
|
|||
58
fastmcp_slim/fastmcp/server/caching.py
Normal file
58
fastmcp_slim/fastmcp/server/caching.py
Normal file
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
219
tests/server/test_cache_hints.py
Normal file
219
tests/server/test_cache_hints.py
Normal file
|
|
@ -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"
|
||||
Loading…
Add table
Add a link
Reference in a new issue