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"