Skip to content

Commit 9a84462

Browse files
committed
Evict cached x-mcp-header map when a re-list drops the tool
list_tools only overwrote the cache on the kept path, so if a tool was first listed with valid x-mcp-header annotations and a later list returned the same name with invalid ones, the tool was dropped but its stale arg->header map survived -- a subsequent tools/call would still mirror the old Mcp-Param-* headers. Evict the entry on the drop path so the cache reflects the last list_tools entry. Found by Codex review.
1 parent b8a88af commit 9a84462

2 files changed

Lines changed: 35 additions & 0 deletions

File tree

‎src/mcp/client/session.py‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -786,6 +786,9 @@ async def list_tools(self, *, params: types.PaginatedRequestParams | None = None
786786
for tool in result.tools:
787787
if (reason := find_invalid_x_mcp_header(tool.input_schema)) is not None:
788788
logger.warning("dropping tool %r: invalid x-mcp-header (%s)", tool.name, reason)
789+
# Evict any map cached from a prior valid listing so a stale entry can't
790+
# mirror headers for a tool this listing dropped.
791+
self._x_mcp_header_maps.pop(tool.name, None)
789792
continue
790793
# Cache the arg→header map so a later tools/call mirrors it into Mcp-Param-* headers.
791794
self._x_mcp_header_maps[tool.name] = x_mcp_header_map(tool.input_schema)

‎tests/client/test_client.py‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -504,6 +504,38 @@ async def on_list_tools(
504504
assert [t.name for t in result.tools] == ["ok", "dropme"]
505505

506506

507+
@pytest.mark.anyio
508+
async def test_modern_list_tools_evicts_header_map_when_a_tool_turns_invalid() -> None:
509+
"""A re-list that drops a previously valid tool must also evict its cached `x-mcp-header` map,
510+
so a later `tools/call` can't mirror stale `Mcp-Param-*` headers for a now-dropped tool."""
511+
valid = types.Tool(
512+
name="run",
513+
input_schema={"type": "object", "properties": {"a": {"type": "string", "x-mcp-header": "Region"}}},
514+
)
515+
invalid = types.Tool(
516+
name="run",
517+
input_schema={"type": "object", "properties": {"a": {"type": "string", "x-mcp-header": "bad name"}}},
518+
)
519+
listings = iter([valid, invalid])
520+
521+
async def on_list_tools(
522+
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
523+
) -> types.ListToolsResult:
524+
return types.ListToolsResult(tools=[next(listings)])
525+
526+
server = Server("test", on_list_tools=on_list_tools)
527+
528+
with anyio.fail_after(5):
529+
async with Client(server) as client:
530+
session = client.session
531+
await client.list_tools()
532+
assert session._resolve_param_headers("run", {"a": "x"}) == {"Mcp-Param-Region": "x"}
533+
534+
result = await client.list_tools()
535+
assert [t.name for t in result.tools] == []
536+
assert session._resolve_param_headers("run", {"a": "x"}) == {}
537+
538+
507539
def test_client_rejects_handshake_era_mode_at_construction() -> None:
508540
"""A handshake-era protocol-version string passed as `mode=` is rejected by
509541
`__post_init__` with a hint to use `mode='legacy'` — the version-pin path is

0 commit comments

Comments
 (0)