Add a client-side response cache honoring SEP-2549 caching hints (#3023)
This commit is contained in:
parent
67d7593df1
commit
b15b1d5f07
4 changed files with 3789 additions and 88 deletions
1087
tests/client/test_caching.py
Normal file
1087
tests/client/test_caching.py
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -506,6 +506,100 @@ async def test_modern_list_tools_drops_tools_with_invalid_x_mcp_header_but_legac
|
|||
assert [t.name for t in result.tools] == ["ok", "dropme"]
|
||||
|
||||
|
||||
_RETIRED_TOOL = Tool(
|
||||
name="retired",
|
||||
input_schema={"type": "object", "properties": {"region": {"type": "string", "x-mcp-header": "Region"}}},
|
||||
output_schema={"type": "object"},
|
||||
)
|
||||
_SURVIVOR_TOOL = Tool(name="survivor", input_schema={"type": "object"})
|
||||
|
||||
|
||||
def _scripted_listing_server(listings: list[ListToolsResult]) -> Server:
|
||||
"""Serves the given listings in order, one per tools/list request."""
|
||||
|
||||
async def on_list_tools(ctx: ServerRequestContext, params: types.PaginatedRequestParams | None) -> ListToolsResult:
|
||||
return listings.pop(0)
|
||||
|
||||
return Server("test", on_list_tools=on_list_tools)
|
||||
|
||||
|
||||
async def test_a_complete_listing_prunes_per_tool_state_for_tools_it_no_longer_contains() -> None:
|
||||
"""SDK-defined: a complete (uncursored, cursorless) listing is the full tool universe, so the
|
||||
header map and output schema derived from an earlier listing of a now-absent tool are dropped."""
|
||||
server = _scripted_listing_server(
|
||||
[
|
||||
ListToolsResult(tools=[_RETIRED_TOOL, _SURVIVOR_TOOL]),
|
||||
ListToolsResult(tools=[_SURVIVOR_TOOL]),
|
||||
]
|
||||
)
|
||||
|
||||
with anyio.fail_after(5):
|
||||
async with Client(server) as client:
|
||||
await client.session.list_tools()
|
||||
assert set(client.session._x_mcp_header_maps) == {"retired", "survivor"}
|
||||
assert set(client.session._tool_output_schemas) == {"retired", "survivor"}
|
||||
|
||||
await client.session.list_tools()
|
||||
assert set(client.session._x_mcp_header_maps) == {"survivor"}
|
||||
assert set(client.session._tool_output_schemas) == {"survivor"}
|
||||
|
||||
|
||||
async def test_a_complete_listing_prunes_output_schemas_on_a_legacy_session_too() -> None:
|
||||
"""SDK-defined: the prune is era-independent -- legacy sessions cache output schemas the same
|
||||
way (their header-map dict just stays empty, since the x-mcp-header filter is 2026-only)."""
|
||||
server = _scripted_listing_server(
|
||||
[
|
||||
ListToolsResult(tools=[_RETIRED_TOOL, _SURVIVOR_TOOL]),
|
||||
ListToolsResult(tools=[_SURVIVOR_TOOL]),
|
||||
]
|
||||
)
|
||||
|
||||
with anyio.fail_after(5):
|
||||
async with Client(server, mode="legacy") as client:
|
||||
await client.session.list_tools()
|
||||
assert set(client.session._tool_output_schemas) == {"retired", "survivor"}
|
||||
assert client.session._x_mcp_header_maps == {}
|
||||
|
||||
await client.session.list_tools()
|
||||
assert set(client.session._tool_output_schemas) == {"survivor"}
|
||||
|
||||
|
||||
async def test_a_listing_with_a_next_cursor_prunes_no_per_tool_state() -> None:
|
||||
"""SDK-defined: a first page carrying next_cursor is not the full universe -- state for tools
|
||||
expected on later pages must survive it."""
|
||||
server = _scripted_listing_server(
|
||||
[
|
||||
ListToolsResult(tools=[_RETIRED_TOOL, _SURVIVOR_TOOL]),
|
||||
ListToolsResult(tools=[_SURVIVOR_TOOL], next_cursor="2"),
|
||||
]
|
||||
)
|
||||
|
||||
with anyio.fail_after(5):
|
||||
async with Client(server) as client:
|
||||
await client.session.list_tools()
|
||||
await client.session.list_tools()
|
||||
assert set(client.session._x_mcp_header_maps) == {"retired", "survivor"}
|
||||
assert set(client.session._tool_output_schemas) == {"retired", "survivor"}
|
||||
|
||||
|
||||
async def test_a_cursor_page_fetch_prunes_no_per_tool_state() -> None:
|
||||
"""SDK-defined: a continuation page is partial even when it ends the pagination (no
|
||||
next_cursor) -- only an uncursored single-page listing prunes."""
|
||||
server = _scripted_listing_server(
|
||||
[
|
||||
ListToolsResult(tools=[_RETIRED_TOOL, _SURVIVOR_TOOL]),
|
||||
ListToolsResult(tools=[_SURVIVOR_TOOL]),
|
||||
]
|
||||
)
|
||||
|
||||
with anyio.fail_after(5):
|
||||
async with Client(server) as client:
|
||||
await client.session.list_tools()
|
||||
await client.session.list_tools(params=types.PaginatedRequestParams(cursor="2"))
|
||||
assert set(client.session._x_mcp_header_maps) == {"retired", "survivor"}
|
||||
assert set(client.session._tool_output_schemas) == {"retired", "survivor"}
|
||||
|
||||
|
||||
def test_client_rejects_handshake_era_mode_at_construction() -> None:
|
||||
"""A handshake-era protocol-version string passed as `mode=` is rejected by
|
||||
`__post_init__` with a hint to use `mode='legacy'` — the version-pin path is
|
||||
|
|
|
|||
1579
tests/client/test_client_caching.py
Normal file
1579
tests/client/test_client_caching.py
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -1661,6 +1661,33 @@ async def test_discover_reraises_unsupported_version_with_malformed_error_data()
|
|||
assert [m for m, _ in dispatcher.calls] == ["server/discover"]
|
||||
|
||||
|
||||
# --- inbound ttlMs clamp ---
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_a_positive_inbound_ttl_reaches_the_result_unchanged() -> None:
|
||||
listing: dict[str, Any] = {"resultType": "complete", "tools": [], "ttlMs": 60_000, "cacheScope": "private"}
|
||||
dispatcher = _ScriptedDispatcher(_discover_result_dict(), listing)
|
||||
with anyio.fail_after(5):
|
||||
async with ClientSession(dispatcher=dispatcher) as session:
|
||||
await session.discover()
|
||||
result = await session.list_tools()
|
||||
assert result.ttl_ms == 60_000
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.parametrize("wire_ttl", [True, False])
|
||||
async def test_a_boolean_inbound_ttl_is_not_clamped_only_coerced_by_validation(wire_ttl: bool) -> None:
|
||||
"""SDK-defined: `bool` is an `int` subclass; the clamp skips it and pydantic's lax mode coerces it instead."""
|
||||
listing: dict[str, Any] = {"resultType": "complete", "tools": [], "ttlMs": wire_ttl, "cacheScope": "private"}
|
||||
dispatcher = _ScriptedDispatcher(_discover_result_dict(), listing)
|
||||
with anyio.fail_after(5):
|
||||
async with ClientSession(dispatcher=dispatcher) as session:
|
||||
await session.discover()
|
||||
result = await session.list_tools()
|
||||
assert result.ttl_ms == int(wire_ttl)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_session_call_tool_returns_input_required_result_when_opted_in() -> None:
|
||||
"""`ClientSession.call_tool(..., allow_input_required=True)` surfaces the
|
||||
|
|
|
|||
Loading…
Reference in a new issue