@@ -506,6 +506,100 @@ async def on_list_tools(
506506 assert [t .name for t in result .tools ] == ["ok" , "dropme" ]
507507
508508
509+ _RETIRED_TOOL = Tool (
510+ name = "retired" ,
511+ input_schema = {"type" : "object" , "properties" : {"region" : {"type" : "string" , "x-mcp-header" : "Region" }}},
512+ output_schema = {"type" : "object" },
513+ )
514+ _SURVIVOR_TOOL = Tool (name = "survivor" , input_schema = {"type" : "object" })
515+
516+
517+ def _scripted_listing_server (listings : list [ListToolsResult ]) -> Server :
518+ """Serves the given listings in order, one per tools/list request."""
519+
520+ async def on_list_tools (ctx : ServerRequestContext , params : types .PaginatedRequestParams | None ) -> ListToolsResult :
521+ return listings .pop (0 )
522+
523+ return Server ("test" , on_list_tools = on_list_tools )
524+
525+
526+ async def test_a_complete_listing_prunes_per_tool_state_for_tools_it_no_longer_contains () -> None :
527+ """SDK-defined: a complete (uncursored, cursorless) listing is the full tool universe, so the
528+ header map and output schema derived from an earlier listing of a now-absent tool are dropped."""
529+ server = _scripted_listing_server (
530+ [
531+ ListToolsResult (tools = [_RETIRED_TOOL , _SURVIVOR_TOOL ]),
532+ ListToolsResult (tools = [_SURVIVOR_TOOL ]),
533+ ]
534+ )
535+
536+ with anyio .fail_after (5 ):
537+ async with Client (server ) as client :
538+ await client .session .list_tools ()
539+ assert set (client .session ._x_mcp_header_maps ) == {"retired" , "survivor" }
540+ assert set (client .session ._tool_output_schemas ) == {"retired" , "survivor" }
541+
542+ await client .session .list_tools ()
543+ assert set (client .session ._x_mcp_header_maps ) == {"survivor" }
544+ assert set (client .session ._tool_output_schemas ) == {"survivor" }
545+
546+
547+ async def test_a_complete_listing_prunes_output_schemas_on_a_legacy_session_too () -> None :
548+ """SDK-defined: the prune is era-independent -- legacy sessions cache output schemas the same
549+ way (their header-map dict just stays empty, since the x-mcp-header filter is 2026-only)."""
550+ server = _scripted_listing_server (
551+ [
552+ ListToolsResult (tools = [_RETIRED_TOOL , _SURVIVOR_TOOL ]),
553+ ListToolsResult (tools = [_SURVIVOR_TOOL ]),
554+ ]
555+ )
556+
557+ with anyio .fail_after (5 ):
558+ async with Client (server , mode = "legacy" ) as client :
559+ await client .session .list_tools ()
560+ assert set (client .session ._tool_output_schemas ) == {"retired" , "survivor" }
561+ assert client .session ._x_mcp_header_maps == {}
562+
563+ await client .session .list_tools ()
564+ assert set (client .session ._tool_output_schemas ) == {"survivor" }
565+
566+
567+ async def test_a_listing_with_a_next_cursor_prunes_no_per_tool_state () -> None :
568+ """SDK-defined: a first page carrying next_cursor is not the full universe -- state for tools
569+ expected on later pages must survive it."""
570+ server = _scripted_listing_server (
571+ [
572+ ListToolsResult (tools = [_RETIRED_TOOL , _SURVIVOR_TOOL ]),
573+ ListToolsResult (tools = [_SURVIVOR_TOOL ], next_cursor = "2" ),
574+ ]
575+ )
576+
577+ with anyio .fail_after (5 ):
578+ async with Client (server ) as client :
579+ await client .session .list_tools ()
580+ await client .session .list_tools ()
581+ assert set (client .session ._x_mcp_header_maps ) == {"retired" , "survivor" }
582+ assert set (client .session ._tool_output_schemas ) == {"retired" , "survivor" }
583+
584+
585+ async def test_a_cursor_page_fetch_prunes_no_per_tool_state () -> None :
586+ """SDK-defined: a continuation page is partial even when it ends the pagination (no
587+ next_cursor) -- only an uncursored single-page listing prunes."""
588+ server = _scripted_listing_server (
589+ [
590+ ListToolsResult (tools = [_RETIRED_TOOL , _SURVIVOR_TOOL ]),
591+ ListToolsResult (tools = [_SURVIVOR_TOOL ]),
592+ ]
593+ )
594+
595+ with anyio .fail_after (5 ):
596+ async with Client (server ) as client :
597+ await client .session .list_tools ()
598+ await client .session .list_tools (params = types .PaginatedRequestParams (cursor = "2" ))
599+ assert set (client .session ._x_mcp_header_maps ) == {"retired" , "survivor" }
600+ assert set (client .session ._tool_output_schemas ) == {"retired" , "survivor" }
601+
602+
509603def test_client_rejects_handshake_era_mode_at_construction () -> None :
510604 """A handshake-era protocol-version string passed as `mode=` is rejected by
511605 `__post_init__` with a hint to use `mode='legacy'` — the version-pin path is
0 commit comments