diff --git a/docs/migration.md b/docs/migration.md index ddd9cd3a65..db2710995b 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -221,6 +221,17 @@ Common renames: 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`). +### Cache hints on list and resource-read results + +`ListToolsResult`, `ListPromptsResult`, `ListResourcesResult`, `ListResourceTemplatesResult`, and `ReadResourceResult` now expose SEP-2549 cache hints: + +- `ttlMs`: non-negative time-to-live value in milliseconds, represented as a JSON number; `0` means the response should be considered immediately stale. +- `cacheScope`: either `"public"` or `"private"`. + +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. + +Code or tests that compare exact JSON payloads should update expected list/read result objects to include the new fields. + ### `args` parameter removed from `ClientSessionGroup.call_tool()` The deprecated `args` parameter has been removed from `ClientSessionGroup.call_tool()`. Use `arguments` instead. diff --git a/src/mcp/types/__init__.py b/src/mcp/types/__init__.py index cb49ff29db..4fdfb8371d 100644 --- a/src/mcp/types/__init__.py +++ b/src/mcp/types/__init__.py @@ -12,6 +12,9 @@ AudioContent, BaseMetadata, BlobResourceContents, + CacheablePaginatedResult, + CacheableResult, + CacheScope, CallToolRequest, CallToolRequestParams, CallToolResult, @@ -182,6 +185,8 @@ "StopReason", # Base classes "BaseMetadata", + "CacheablePaginatedResult", + "CacheableResult", "Request", "Notification", "Result", @@ -240,6 +245,7 @@ "Tool", "ToolAnnotations", "ToolChoice", + "CacheScope", # Requests "CallToolRequest", "CallToolRequestParams", diff --git a/src/mcp/types/_types.py b/src/mcp/types/_types.py index e9d39ef6f3..844b2027eb 100644 --- a/src/mcp/types/_types.py +++ b/src/mcp/types/_types.py @@ -26,6 +26,7 @@ ProgressToken = str | int Role = Literal["user", "assistant"] +CacheScope: TypeAlias = Literal["public", "private"] IconTheme = Literal["light", "dark"] @@ -104,6 +105,16 @@ class Result(MCPModel): """ +class CacheableResult(Result): + """A result that supports cache hints.""" + + ttl_ms: Annotated[int, Field(ge=0)] = 0 + """Time-to-live in milliseconds. A value of 0 means the result should be considered immediately stale.""" + + cache_scope: CacheScope = "public" + """The intended cache scope for this result.""" + + class PaginatedResult(Result): next_cursor: str | None = None """ @@ -112,6 +123,16 @@ class PaginatedResult(Result): """ +class CacheablePaginatedResult(PaginatedResult): + """A paginated result that supports cache hints.""" + + ttl_ms: Annotated[int, Field(ge=0)] = 0 + """Time-to-live in milliseconds. A value of 0 means the result should be considered immediately stale.""" + + cache_scope: CacheScope = "public" + """The intended cache scope for this result.""" + + class EmptyResult(Result): """A response that indicates success but carries no data.""" @@ -445,7 +466,7 @@ class ResourceTemplate(BaseMetadata): """ -class ListResourcesResult(PaginatedResult): +class ListResourcesResult(CacheablePaginatedResult): """The server's response to a resources/list request from the client.""" resources: list[Resource] @@ -457,7 +478,7 @@ class ListResourceTemplatesRequest(PaginatedRequest[Literal["resources/templates method: Literal["resources/templates/list"] = "resources/templates/list" -class ListResourceTemplatesResult(PaginatedResult): +class ListResourceTemplatesResult(CacheablePaginatedResult): """The server's response to a resources/templates/list request from the client.""" resource_templates: list[ResourceTemplate] @@ -511,7 +532,7 @@ class BlobResourceContents(ResourceContents): """A base64-encoded string representing the binary data of the item.""" -class ReadResourceResult(Result): +class ReadResourceResult(CacheableResult): """The server's response to a resources/read request from the client.""" contents: list[TextResourceContents | BlobResourceContents] @@ -617,7 +638,7 @@ class Prompt(BaseMetadata): """ -class ListPromptsResult(PaginatedResult): +class ListPromptsResult(CacheablePaginatedResult): """The server's response to a prompts/list request from the client.""" prompts: list[Prompt] @@ -915,7 +936,7 @@ class Tool(BaseMetadata): """ -class ListToolsResult(PaginatedResult): +class ListToolsResult(CacheablePaginatedResult): """The server's response to a tools/list request from the client.""" tools: list[Tool] diff --git a/tests/interaction/_requirements.py b/tests/interaction/_requirements.py index caed8905d0..a8806fd1b2 100644 --- a/tests/interaction/_requirements.py +++ b/tests/interaction/_requirements.py @@ -595,6 +595,11 @@ def __post_init__(self) -> None: source=f"{SPEC_BASE_URL}/server/tools#listing-tools", behavior="tools/list returns the registered tools with name, description, and inputSchema.", ), + "tools:list:cache-hints": Requirement( + source="issue:#2802", + behavior="tools/list responses include SEP-2549 cache hints on the wire.", + transports=("in-memory",), + ), "tools:list:metadata": Requirement( source=f"{SPEC_BASE_URL}/server/tools#tool", behavior=( diff --git a/tests/interaction/lowlevel/test_wire.py b/tests/interaction/lowlevel/test_wire.py index 0f9c58aa7a..d76690e744 100644 --- a/tests/interaction/lowlevel/test_wire.py +++ b/tests/interaction/lowlevel/test_wire.py @@ -194,6 +194,28 @@ async def call_and_capture_error() -> None: assert errors == snapshot([ErrorData(code=CONNECTION_CLOSED, message="Connection closed")]) +@requirement("tools:list:cache-hints") +async def test_list_tools_response_includes_cache_hints_on_the_wire() -> None: + recording = RecordingTransport(InMemoryTransport(_echo_server())) + + async with Client(recording) as client: + await client.list_tools() + + sent_requests = [message.message for message in recording.sent if isinstance(message.message, JSONRPCRequest)] + list_tools_request = next(request for request in sent_requests if request.method == "tools/list") + + received_responses = [ + message.message + for message in recording.received + if isinstance(message, SessionMessage) and isinstance(message.message, JSONRPCResponse) + ] + list_tools_response = next(response for response in received_responses if response.id == list_tools_request.id) + + assert isinstance(list_tools_response.result, dict) + assert list_tools_response.result["ttlMs"] == 0 + assert list_tools_response.result["cacheScope"] == "public" + + @requirement("protocol:error:invalid-params") async def test_malformed_request_params_are_answered_with_invalid_params() -> None: """A request whose params fail validation is answered with -32602 Invalid params. diff --git a/tests/server/lowlevel/test_server_listing.py b/tests/server/lowlevel/test_server_listing.py index 2c3d303a92..0cf82c802a 100644 --- a/tests/server/lowlevel/test_server_listing.py +++ b/tests/server/lowlevel/test_server_listing.py @@ -32,6 +32,8 @@ async def handle_list_prompts( async with Client(server) as client: result = await client.list_prompts() assert result.prompts == test_prompts + assert result.ttl_ms == 0 + assert result.cache_scope == "public" @pytest.mark.anyio @@ -51,6 +53,8 @@ async def handle_list_resources( async with Client(server) as client: result = await client.list_resources() assert result.resources == test_resources + assert result.ttl_ms == 0 + assert result.cache_scope == "public" @pytest.mark.anyio @@ -89,6 +93,8 @@ async def handle_list_tools(ctx: ServerRequestContext, params: PaginatedRequestP async with Client(server) as client: result = await client.list_tools() assert result.tools == test_tools + assert result.ttl_ms == 0 + assert result.cache_scope == "public" @pytest.mark.anyio @@ -104,6 +110,8 @@ async def handle_list_prompts( async with Client(server) as client: result = await client.list_prompts() assert result.prompts == [] + assert result.ttl_ms == 0 + assert result.cache_scope == "public" @pytest.mark.anyio @@ -119,6 +127,8 @@ async def handle_list_resources( async with Client(server) as client: result = await client.list_resources() assert result.resources == [] + assert result.ttl_ms == 0 + assert result.cache_scope == "public" @pytest.mark.anyio @@ -132,3 +142,18 @@ async def handle_list_tools(ctx: ServerRequestContext, params: PaginatedRequestP async with Client(server) as client: result = await client.list_tools() assert result.tools == [] + assert result.ttl_ms == 0 + assert result.cache_scope == "public" + + +@pytest.mark.anyio +async def test_list_tools_can_return_explicit_cache_hints() -> None: + async def handle_list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult: + return ListToolsResult(tools=[], ttl_ms=1_000, cache_scope="private") + + server = Server("test", on_list_tools=handle_list_tools) + async with Client(server) as client: + result = await client.list_tools() + + assert result.ttl_ms == 1_000 + assert result.cache_scope == "private" diff --git a/tests/server/mcpserver/test_server.py b/tests/server/mcpserver/test_server.py index 21352b5f2f..5c7c8d1b0f 100644 --- a/tests/server/mcpserver/test_server.py +++ b/tests/server/mcpserver/test_server.py @@ -44,6 +44,38 @@ pytestmark = pytest.mark.anyio +async def test_mcpserver_list_and_read_results_include_default_cache_hints() -> None: + mcp = MCPServer("cache-hints") + + @mcp.tool() + def ping() -> str: + return "pong" + + @mcp.prompt() + def greet(name: str) -> str: + return f"Hello, {name}" + + @mcp.resource("file:///hello.txt") + def hello() -> str: + return "hello" + + async with Client(mcp) as client: + tools = await client.list_tools() + prompts = await client.list_prompts() + resources = await client.list_resources() + templates = await client.list_resource_templates() + content = await client.read_resource("file:///hello.txt") + tool_result = await client.call_tool("ping", {}) + prompt_result = await client.get_prompt("greet", {"name": "Ada"}) + + for result in (tools, prompts, resources, templates, content): + assert result.ttl_ms == 0 + assert result.cache_scope == "public" + + assert tool_result.content == [TextContent(text="pong")] + assert prompt_result.messages == [PromptMessage(role="user", content=TextContent(text="Hello, Ada"))] + + class TestServer: async def test_create_server(self): mcp = MCPServer( diff --git a/tests/test_types.py b/tests/test_types.py index f424efdbf7..0097d0fd22 100644 --- a/tests/test_types.py +++ b/tests/test_types.py @@ -1,6 +1,7 @@ from typing import Any import pytest +from pydantic import ValidationError from mcp.types import ( LATEST_PROTOCOL_VERSION, @@ -12,10 +13,15 @@ InitializeRequest, InitializeRequestParams, JSONRPCRequest, + ListPromptsResult, + ListResourcesResult, + ListResourceTemplatesResult, ListToolsResult, + ReadResourceResult, SamplingCapability, SamplingMessage, TextContent, + TextResourceContents, Tool, ToolChoice, ToolResultContent, @@ -360,3 +366,69 @@ def test_list_tools_result_preserves_json_schema_2020_12_fields(): assert tool.input_schema["$schema"] == "https://json-schema.org/draft/2020-12/schema" assert "$defs" in tool.input_schema assert tool.input_schema["additionalProperties"] is False + + +def test_cacheable_results_serialize_default_cache_hints() -> None: + models = [ + ListToolsResult(tools=[]), + ListPromptsResult(prompts=[]), + ListResourcesResult(resources=[]), + ListResourceTemplatesResult(resource_templates=[]), + ReadResourceResult(contents=[]), + ] + + for model in models: + serialized = model.model_dump(mode="json", by_alias=True, exclude_none=True) + assert serialized["ttlMs"] == 0 + assert serialized["cacheScope"] == "public" + + +def test_cacheable_results_accept_explicit_cache_hints() -> None: + result = ListToolsResult(tools=[], ttl_ms=60_000, cache_scope="private") + + assert result.ttl_ms == 60_000 + assert result.cache_scope == "private" + assert result.model_dump(mode="json", by_alias=True, exclude_none=True) == { + "ttlMs": 60_000, + "cacheScope": "private", + "tools": [], + } + + +def test_cacheable_results_parse_camel_case_cache_hints() -> None: + result = ReadResourceResult.model_validate( + { + "ttlMs": 250, + "cacheScope": "private", + "contents": [ + { + "uri": "file:///a.txt", + "mimeType": "text/plain", + "text": "hello", + } + ], + } + ) + + assert result.ttl_ms == 250 + assert result.cache_scope == "private" + assert result.contents == [TextResourceContents(uri="file:///a.txt", mime_type="text/plain", text="hello")] + + +def test_cacheable_results_keep_legacy_missing_hints_usable() -> None: + result = ListResourcesResult.model_validate({"resources": [{"uri": "file:///a.txt", "name": "A"}]}) + + assert result.ttl_ms == 0 + assert result.cache_scope == "public" + assert result.resources[0].uri == "file:///a.txt" + assert result.resources[0].name == "A" + + +def test_cacheable_results_reject_negative_ttl() -> None: + with pytest.raises(ValidationError): + ListPromptsResult.model_validate({"ttlMs": -1, "cacheScope": "public", "prompts": []}) + + +def test_cacheable_results_reject_invalid_cache_scope() -> None: + with pytest.raises(ValidationError): + ListToolsResult.model_validate({"ttlMs": 1, "cacheScope": "shared", "tools": []})