Skip to content

Commit 8c3934f

Browse files
committed
feat(types): add SEP-2549 cache hints to result models
Add cache hint fields to list and resource-read protocol result models with conservative defaults of ttlMs=0 and cacheScope=public. Cover default serialization, explicit hints, validation, low-level wire behavior, high-level MCPServer defaults, and migration notes without adding new high-level server configuration API.
1 parent 6d0c160 commit 8c3934f

8 files changed

Lines changed: 194 additions & 5 deletions

File tree

docs/migration.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,17 @@ Common renames:
221221

222222
Because `populate_by_name=True` is set, the old camelCase names still work as constructor kwargs (e.g., `Tool(inputSchema={...})` is accepted), but attribute access must use snake_case (`tool.input_schema`).
223223

224+
### Cache hints on list and resource-read results
225+
226+
`ListToolsResult`, `ListPromptsResult`, `ListResourcesResult`, `ListResourceTemplatesResult`, and `ReadResourceResult` now expose SEP-2549 cache hints:
227+
228+
- `ttlMs`: non-negative time-to-live value in milliseconds, represented as a JSON number; `0` means the response should be considered immediately stale.
229+
- `cacheScope`: either `"public"` or `"private"`.
230+
231+
Existing Python code that constructs these models without cache fields continues to work because the SDK defaults to `ttlMs=0` and `cacheScope="public"`. Clients parsing older server responses that omit these fields will also receive those defaults.
232+
233+
Code or tests that compare exact JSON payloads should update expected list/read result objects to include the new fields.
234+
224235
### `args` parameter removed from `ClientSessionGroup.call_tool()`
225236

226237
The deprecated `args` parameter has been removed from `ClientSessionGroup.call_tool()`. Use `arguments` instead.

src/mcp/types/__init__.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@
1212
AudioContent,
1313
BaseMetadata,
1414
BlobResourceContents,
15+
CacheablePaginatedResult,
16+
CacheableResult,
17+
CacheScope,
1518
CallToolRequest,
1619
CallToolRequestParams,
1720
CallToolResult,
@@ -182,6 +185,8 @@
182185
"StopReason",
183186
# Base classes
184187
"BaseMetadata",
188+
"CacheablePaginatedResult",
189+
"CacheableResult",
185190
"Request",
186191
"Notification",
187192
"Result",
@@ -240,6 +245,7 @@
240245
"Tool",
241246
"ToolAnnotations",
242247
"ToolChoice",
248+
"CacheScope",
243249
# Requests
244250
"CallToolRequest",
245251
"CallToolRequestParams",

src/mcp/types/_types.py

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626

2727
ProgressToken = str | int
2828
Role = Literal["user", "assistant"]
29+
CacheScope: TypeAlias = Literal["public", "private"]
2930

3031
IconTheme = Literal["light", "dark"]
3132

@@ -104,6 +105,16 @@ class Result(MCPModel):
104105
"""
105106

106107

108+
class CacheableResult(Result):
109+
"""A result that supports cache hints."""
110+
111+
ttl_ms: Annotated[int, Field(ge=0)] = 0
112+
"""Time-to-live in milliseconds. A value of 0 means the result should be considered immediately stale."""
113+
114+
cache_scope: CacheScope = "public"
115+
"""The intended cache scope for this result."""
116+
117+
107118
class PaginatedResult(Result):
108119
next_cursor: str | None = None
109120
"""
@@ -112,6 +123,16 @@ class PaginatedResult(Result):
112123
"""
113124

114125

126+
class CacheablePaginatedResult(PaginatedResult):
127+
"""A paginated result that supports cache hints."""
128+
129+
ttl_ms: Annotated[int, Field(ge=0)] = 0
130+
"""Time-to-live in milliseconds. A value of 0 means the result should be considered immediately stale."""
131+
132+
cache_scope: CacheScope = "public"
133+
"""The intended cache scope for this result."""
134+
135+
115136
class EmptyResult(Result):
116137
"""A response that indicates success but carries no data."""
117138

@@ -445,7 +466,7 @@ class ResourceTemplate(BaseMetadata):
445466
"""
446467

447468

448-
class ListResourcesResult(PaginatedResult):
469+
class ListResourcesResult(CacheablePaginatedResult):
449470
"""The server's response to a resources/list request from the client."""
450471

451472
resources: list[Resource]
@@ -457,7 +478,7 @@ class ListResourceTemplatesRequest(PaginatedRequest[Literal["resources/templates
457478
method: Literal["resources/templates/list"] = "resources/templates/list"
458479

459480

460-
class ListResourceTemplatesResult(PaginatedResult):
481+
class ListResourceTemplatesResult(CacheablePaginatedResult):
461482
"""The server's response to a resources/templates/list request from the client."""
462483

463484
resource_templates: list[ResourceTemplate]
@@ -511,7 +532,7 @@ class BlobResourceContents(ResourceContents):
511532
"""A base64-encoded string representing the binary data of the item."""
512533

513534

514-
class ReadResourceResult(Result):
535+
class ReadResourceResult(CacheableResult):
515536
"""The server's response to a resources/read request from the client."""
516537

517538
contents: list[TextResourceContents | BlobResourceContents]
@@ -617,7 +638,7 @@ class Prompt(BaseMetadata):
617638
"""
618639

619640

620-
class ListPromptsResult(PaginatedResult):
641+
class ListPromptsResult(CacheablePaginatedResult):
621642
"""The server's response to a prompts/list request from the client."""
622643

623644
prompts: list[Prompt]
@@ -915,7 +936,7 @@ class Tool(BaseMetadata):
915936
"""
916937

917938

918-
class ListToolsResult(PaginatedResult):
939+
class ListToolsResult(CacheablePaginatedResult):
919940
"""The server's response to a tools/list request from the client."""
920941

921942
tools: list[Tool]

tests/interaction/_requirements.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -595,6 +595,11 @@ def __post_init__(self) -> None:
595595
source=f"{SPEC_BASE_URL}/server/tools#listing-tools",
596596
behavior="tools/list returns the registered tools with name, description, and inputSchema.",
597597
),
598+
"tools:list:cache-hints": Requirement(
599+
source="issue:#2802",
600+
behavior="tools/list responses include SEP-2549 cache hints on the wire.",
601+
transports=("in-memory",),
602+
),
598603
"tools:list:metadata": Requirement(
599604
source=f"{SPEC_BASE_URL}/server/tools#tool",
600605
behavior=(

tests/interaction/lowlevel/test_wire.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,28 @@ async def call_and_capture_error() -> None:
194194
assert errors == snapshot([ErrorData(code=CONNECTION_CLOSED, message="Connection closed")])
195195

196196

197+
@requirement("tools:list:cache-hints")
198+
async def test_list_tools_response_includes_cache_hints_on_the_wire() -> None:
199+
recording = RecordingTransport(InMemoryTransport(_echo_server()))
200+
201+
async with Client(recording) as client:
202+
await client.list_tools()
203+
204+
sent_requests = [message.message for message in recording.sent if isinstance(message.message, JSONRPCRequest)]
205+
list_tools_request = next(request for request in sent_requests if request.method == "tools/list")
206+
207+
received_responses = [
208+
message.message
209+
for message in recording.received
210+
if isinstance(message, SessionMessage) and isinstance(message.message, JSONRPCResponse)
211+
]
212+
list_tools_response = next(response for response in received_responses if response.id == list_tools_request.id)
213+
214+
assert isinstance(list_tools_response.result, dict)
215+
assert list_tools_response.result["ttlMs"] == 0
216+
assert list_tools_response.result["cacheScope"] == "public"
217+
218+
197219
@requirement("protocol:error:invalid-params")
198220
async def test_malformed_request_params_are_answered_with_invalid_params() -> None:
199221
"""A request whose params fail validation is answered with -32602 Invalid params.

tests/server/lowlevel/test_server_listing.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ async def handle_list_prompts(
3232
async with Client(server) as client:
3333
result = await client.list_prompts()
3434
assert result.prompts == test_prompts
35+
assert result.ttl_ms == 0
36+
assert result.cache_scope == "public"
3537

3638

3739
@pytest.mark.anyio
@@ -51,6 +53,8 @@ async def handle_list_resources(
5153
async with Client(server) as client:
5254
result = await client.list_resources()
5355
assert result.resources == test_resources
56+
assert result.ttl_ms == 0
57+
assert result.cache_scope == "public"
5458

5559

5660
@pytest.mark.anyio
@@ -89,6 +93,8 @@ async def handle_list_tools(ctx: ServerRequestContext, params: PaginatedRequestP
8993
async with Client(server) as client:
9094
result = await client.list_tools()
9195
assert result.tools == test_tools
96+
assert result.ttl_ms == 0
97+
assert result.cache_scope == "public"
9298

9399

94100
@pytest.mark.anyio
@@ -104,6 +110,8 @@ async def handle_list_prompts(
104110
async with Client(server) as client:
105111
result = await client.list_prompts()
106112
assert result.prompts == []
113+
assert result.ttl_ms == 0
114+
assert result.cache_scope == "public"
107115

108116

109117
@pytest.mark.anyio
@@ -119,6 +127,8 @@ async def handle_list_resources(
119127
async with Client(server) as client:
120128
result = await client.list_resources()
121129
assert result.resources == []
130+
assert result.ttl_ms == 0
131+
assert result.cache_scope == "public"
122132

123133

124134
@pytest.mark.anyio
@@ -132,3 +142,18 @@ async def handle_list_tools(ctx: ServerRequestContext, params: PaginatedRequestP
132142
async with Client(server) as client:
133143
result = await client.list_tools()
134144
assert result.tools == []
145+
assert result.ttl_ms == 0
146+
assert result.cache_scope == "public"
147+
148+
149+
@pytest.mark.anyio
150+
async def test_list_tools_can_return_explicit_cache_hints() -> None:
151+
async def handle_list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult:
152+
return ListToolsResult(tools=[], ttl_ms=1_000, cache_scope="private")
153+
154+
server = Server("test", on_list_tools=handle_list_tools)
155+
async with Client(server) as client:
156+
result = await client.list_tools()
157+
158+
assert result.ttl_ms == 1_000
159+
assert result.cache_scope == "private"

tests/server/mcpserver/test_server.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,33 @@
4444
pytestmark = pytest.mark.anyio
4545

4646

47+
async def test_mcpserver_list_and_read_results_include_default_cache_hints() -> None:
48+
mcp = MCPServer("cache-hints")
49+
50+
@mcp.tool()
51+
def ping() -> str:
52+
return "pong"
53+
54+
@mcp.prompt()
55+
def greet(name: str) -> str:
56+
return f"Hello, {name}"
57+
58+
@mcp.resource("file:///hello.txt")
59+
def hello() -> str:
60+
return "hello"
61+
62+
async with Client(mcp) as client:
63+
tools = await client.list_tools()
64+
prompts = await client.list_prompts()
65+
resources = await client.list_resources()
66+
templates = await client.list_resource_templates()
67+
content = await client.read_resource("file:///hello.txt")
68+
69+
for result in (tools, prompts, resources, templates, content):
70+
assert result.ttl_ms == 0
71+
assert result.cache_scope == "public"
72+
73+
4774
class TestServer:
4875
async def test_create_server(self):
4976
mcp = MCPServer(

tests/test_types.py

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from typing import Any
22

33
import pytest
4+
from pydantic import ValidationError
45

56
from mcp.types import (
67
LATEST_PROTOCOL_VERSION,
@@ -12,10 +13,15 @@
1213
InitializeRequest,
1314
InitializeRequestParams,
1415
JSONRPCRequest,
16+
ListPromptsResult,
17+
ListResourcesResult,
18+
ListResourceTemplatesResult,
1519
ListToolsResult,
20+
ReadResourceResult,
1621
SamplingCapability,
1722
SamplingMessage,
1823
TextContent,
24+
TextResourceContents,
1925
Tool,
2026
ToolChoice,
2127
ToolResultContent,
@@ -360,3 +366,69 @@ def test_list_tools_result_preserves_json_schema_2020_12_fields():
360366
assert tool.input_schema["$schema"] == "https://json-schema.org/draft/2020-12/schema"
361367
assert "$defs" in tool.input_schema
362368
assert tool.input_schema["additionalProperties"] is False
369+
370+
371+
def test_cacheable_results_serialize_default_cache_hints() -> None:
372+
models = [
373+
ListToolsResult(tools=[]),
374+
ListPromptsResult(prompts=[]),
375+
ListResourcesResult(resources=[]),
376+
ListResourceTemplatesResult(resource_templates=[]),
377+
ReadResourceResult(contents=[]),
378+
]
379+
380+
for model in models:
381+
serialized = model.model_dump(mode="json", by_alias=True, exclude_none=True)
382+
assert serialized["ttlMs"] == 0
383+
assert serialized["cacheScope"] == "public"
384+
385+
386+
def test_cacheable_results_accept_explicit_cache_hints() -> None:
387+
result = ListToolsResult(tools=[], ttl_ms=60_000, cache_scope="private")
388+
389+
assert result.ttl_ms == 60_000
390+
assert result.cache_scope == "private"
391+
assert result.model_dump(mode="json", by_alias=True, exclude_none=True) == {
392+
"ttlMs": 60_000,
393+
"cacheScope": "private",
394+
"tools": [],
395+
}
396+
397+
398+
def test_cacheable_results_parse_camel_case_cache_hints() -> None:
399+
result = ReadResourceResult.model_validate(
400+
{
401+
"ttlMs": 250,
402+
"cacheScope": "private",
403+
"contents": [
404+
{
405+
"uri": "file:///a.txt",
406+
"mimeType": "text/plain",
407+
"text": "hello",
408+
}
409+
],
410+
}
411+
)
412+
413+
assert result.ttl_ms == 250
414+
assert result.cache_scope == "private"
415+
assert result.contents == [TextResourceContents(uri="file:///a.txt", mime_type="text/plain", text="hello")]
416+
417+
418+
def test_cacheable_results_keep_legacy_missing_hints_usable() -> None:
419+
result = ListResourcesResult.model_validate({"resources": [{"uri": "file:///a.txt", "name": "A"}]})
420+
421+
assert result.ttl_ms == 0
422+
assert result.cache_scope == "public"
423+
assert result.resources[0].uri == "file:///a.txt"
424+
assert result.resources[0].name == "A"
425+
426+
427+
def test_cacheable_results_reject_negative_ttl() -> None:
428+
with pytest.raises(ValidationError):
429+
ListPromptsResult.model_validate({"ttlMs": -1, "cacheScope": "public", "prompts": []})
430+
431+
432+
def test_cacheable_results_reject_invalid_cache_scope() -> None:
433+
with pytest.raises(ValidationError):
434+
ListToolsResult.model_validate({"ttlMs": 1, "cacheScope": "shared", "tools": []})

0 commit comments

Comments
 (0)