Skip to content

Commit 6f114fc

Browse files
committed
fix(client): list_all_tools prunes per-tool cache state on terminal page
Cubic P2 on PR #3083: a multi-page list_all_tools() drain called list_tools() with a non-None cursor on every page, so _absorb_tool_listing(complete=True) was never reached and the per-tool cache (_x_mcp_header_maps, _tool_output_schemas) was left in an additive-only state. Tools that had been present in a prior list_tools() call but were absent from the drained listing kept their cache entries indefinitely. Run _absorb_tool_listing once more with complete=True on the merged result after the loop ends. The merged listing is the drained universe, so complete=True prunes cache entries for tools no longer present, mirroring the single-page complete listing semantic. Test: seed the cache with stale-tool via a single-page complete listing, then drain survivor across two pages, assert the cache reflects only the drained universe. A second test asserts the inverse: if every drained tool is in the union, all cache entries stay.
1 parent 66a12d4 commit 6f114fc

2 files changed

Lines changed: 84 additions & 4 deletions

File tree

src/mcp/client/session.py

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1195,9 +1195,13 @@ def _absorb_tool_listing(self, result: types.ListToolsResult, *, complete: bool)
11951195
async def list_all_tools(self) -> list[types.Tool]:
11961196
"""Drain pagination across `tools/list` and return the full tool list.
11971197
1198-
Each page flows through the 2026 x-mcp-header per-page filter via
1199-
`list_tools` -> `_absorb_tool_listing`, so the returned list is exactly
1200-
what the cached state would expose.
1198+
Each intermediate page flows through `list_tools` -> `_absorb_tool_listing`
1199+
with `complete=False`, which populates per-tool cache state (`_x_mcp_header_maps`,
1200+
`_tool_output_schemas`) as a pure addition. After the loop ends, the helper
1201+
runs `_absorb_tool_listing` once more with `complete=True` on the merged
1202+
result so state for tools that disappeared across pages (or were filtered
1203+
by the per-page x-mcp-header MUST) is pruned to match the drained universe.
1204+
The returned list reflects the post-filter view that the cache exposes.
12011205
"""
12021206
tools: list[types.Tool] = []
12031207
cursor: str | None = None
@@ -1206,7 +1210,12 @@ async def list_all_tools(self) -> list[types.Tool]:
12061210
tools.extend(result.tools)
12071211
cursor = result.next_cursor
12081212
if cursor is None:
1209-
return tools
1213+
break
1214+
# Final absorption pass with `complete=True` so the per-tool cache matches
1215+
# the drained listing (prunes entries for tools no longer present).
1216+
merged = types.ListToolsResult(tools=tools, next_cursor=None)
1217+
absorbed = self._absorb_tool_listing(merged, complete=True)
1218+
return absorbed.tools
12101219

12111220
async def list_all_resources(self) -> list[types.Resource]:
12121221
"""Drain pagination across `resources/list` and return the full resource list."""

tests/client/test_client.py

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1158,3 +1158,74 @@ async def on_list_tools(
11581158
tools = await client.session.list_all_tools()
11591159
assert captured == [None, "next-cursor"]
11601160
assert [t.name for t in tools] == ["page1-item", "page2-item"]
1161+
1162+
1163+
async def test_list_all_tools_prunes_per_tool_cache_state_for_tools_dropped_across_pages() -> None:
1164+
"""Cubic P2 fix: after a multi-page drain, per-tool cache state (`_x_mcp_header_maps`,
1165+
`_tool_output_schemas`) must reflect the merged listing (the drained universe). Tools
1166+
that appeared in a previous `list_tools()` call but are not in the drained listing
1167+
should not leave stale cache entries (the merged-listing absorption pass with
1168+
`complete=True` prunes them just like a complete single-page listing would)."""
1169+
1170+
survivor = types.Tool(
1171+
name="survivor",
1172+
input_schema={"type": "object", "properties": {"region": {"type": "string", "x-mcp-header": "Region"}}},
1173+
)
1174+
stale_tool = types.Tool(name="stale-tool", input_schema={"type": "object"})
1175+
seen_pages: list[str | None] = []
1176+
1177+
async def on_list_tools(
1178+
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
1179+
) -> types.ListToolsResult:
1180+
cursor = (params.cursor if params else None) or "p1"
1181+
seen_pages.append(cursor)
1182+
if seen_pages == ["p1"]:
1183+
# First call: complete=True listing that seeds the stale tool.
1184+
return types.ListToolsResult(tools=[stale_tool])
1185+
if cursor == "p1":
1186+
return types.ListToolsResult(tools=[survivor], next_cursor="p2")
1187+
return types.ListToolsResult(tools=[survivor])
1188+
1189+
server = Server("test", on_list_tools=on_list_tools)
1190+
1191+
with anyio.fail_after(5):
1192+
async with Client(server) as client:
1193+
# Seed: single-page complete listing with stale-tool.
1194+
seeded = await client.session.list_tools()
1195+
assert [t.name for t in seeded.tools] == ["stale-tool"]
1196+
assert set(client.session._x_mcp_header_maps) == {"stale-tool"}
1197+
1198+
# Drain: multi-page listing of survivor only.
1199+
drained = await client.session.list_all_tools()
1200+
assert [t.name for t in drained] == ["survivor", "survivor"]
1201+
# After drain, cache reflects only the drained universe.
1202+
assert set(client.session._x_mcp_header_maps) == {"survivor"}
1203+
assert set(client.session._tool_output_schemas) == {"survivor"}
1204+
1205+
1206+
async def test_list_all_tools_does_not_prune_when_server_state_remains_intact() -> None:
1207+
"""Sanity: if the drained listing is the only listing seen so far, every tool in
1208+
the union stays in the cache."""
1209+
1210+
a = types.Tool(name="a", input_schema={"type": "object"})
1211+
b = types.Tool(name="b", input_schema={"type": "object"})
1212+
c = types.Tool(name="c", input_schema={"type": "object"})
1213+
1214+
pages: list[types.ListToolsResult] = [
1215+
types.ListToolsResult(tools=[a, b], next_cursor="p2"),
1216+
types.ListToolsResult(tools=[c]),
1217+
]
1218+
1219+
async def on_list_tools(
1220+
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
1221+
) -> types.ListToolsResult:
1222+
cursor = (params.cursor if params else None) or "p1"
1223+
return pages[0] if cursor == "p1" else pages[1]
1224+
1225+
server = Server("test", on_list_tools=on_list_tools)
1226+
1227+
with anyio.fail_after(5):
1228+
async with Client(server) as client:
1229+
await client.session.list_all_tools()
1230+
assert set(client.session._x_mcp_header_maps) == {"a", "b", "c"}
1231+
assert set(client.session._tool_output_schemas) == {"a", "b", "c"}

0 commit comments

Comments
 (0)