Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions docs/migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
6 changes: 6 additions & 0 deletions src/mcp/types/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@
AudioContent,
BaseMetadata,
BlobResourceContents,
CacheablePaginatedResult,
CacheableResult,
CacheScope,
CallToolRequest,
CallToolRequestParams,
CallToolResult,
Expand Down Expand Up @@ -182,6 +185,8 @@
"StopReason",
# Base classes
"BaseMetadata",
"CacheablePaginatedResult",
"CacheableResult",
"Request",
"Notification",
"Result",
Expand Down Expand Up @@ -240,6 +245,7 @@
"Tool",
"ToolAnnotations",
"ToolChoice",
"CacheScope",
# Requests
"CallToolRequest",
"CallToolRequestParams",
Expand Down
31 changes: 26 additions & 5 deletions src/mcp/types/_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@

ProgressToken = str | int
Role = Literal["user", "assistant"]
CacheScope: TypeAlias = Literal["public", "private"]

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

Expand Down Expand Up @@ -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
"""
Expand All @@ -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."""

Expand Down Expand Up @@ -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]
Expand All @@ -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]
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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]
Expand Down
5 changes: 5 additions & 0 deletions tests/interaction/_requirements.py
Original file line number Diff line number Diff line change
Expand Up @@ -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=(
Expand Down
22 changes: 22 additions & 0 deletions tests/interaction/lowlevel/test_wire.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
25 changes: 25 additions & 0 deletions tests/server/lowlevel/test_server_listing.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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"
32 changes: 32 additions & 0 deletions tests/server/mcpserver/test_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
72 changes: 72 additions & 0 deletions tests/test_types.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from typing import Any

import pytest
from pydantic import ValidationError

from mcp.types import (
LATEST_PROTOCOL_VERSION,
Expand All @@ -12,10 +13,15 @@
InitializeRequest,
InitializeRequestParams,
JSONRPCRequest,
ListPromptsResult,
ListResourcesResult,
ListResourceTemplatesResult,
ListToolsResult,
ReadResourceResult,
SamplingCapability,
SamplingMessage,
TextContent,
TextResourceContents,
Tool,
ToolChoice,
ToolResultContent,
Expand Down Expand Up @@ -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": []})
Loading