Skip to content

Commit b44a891

Browse files
committed
Address review feedback on identity stripping and stale tool-map pruning
1 parent af36ead commit b44a891

6 files changed

Lines changed: 176 additions & 22 deletions

File tree

src/mcp/client/client.py

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@
99
from contextlib import AsyncExitStack
1010
from dataclasses import KW_ONLY, dataclass, field
1111
from typing import Any, Literal, TypeVar, cast
12-
from urllib.parse import urlsplit
1312

1413
import anyio
1514
import anyio.lowlevel
@@ -132,11 +131,19 @@ def _strip_userinfo(url: str) -> str:
132131
133132
Credentials must not enter cache-key material; any further normalization could merge distinct servers.
134133
"""
135-
netloc = urlsplit(url).netloc # raw authority bytes (urlsplit case-folds only `.scheme`), so slicing is exact
136-
if "@" not in netloc:
134+
# Pure text, no urlsplit: it strips embedded tab/CR/LF before parsing, which would misalign slices.
135+
sep = url.find("//")
136+
if sep == -1:
137137
return url
138-
start = url.index("//") + 2
139-
return url[:start] + netloc.rpartition("@")[2] + url[start + len(netloc) :]
138+
start = sep + 2
139+
end = len(url)
140+
for delimiter in "/?#":
141+
if (found := url.find(delimiter, start)) != -1:
142+
end = min(end, found)
143+
authority = url[start:end]
144+
if "@" not in authority:
145+
return url
146+
return url[:start] + authority.rpartition("@")[2] + url[end:]
140147

141148

142149
def _evicting_message_handler(cache: ClientResponseCache, user_handler: MessageHandlerFnT | None) -> MessageHandlerFnT:
@@ -746,9 +753,12 @@ async def list_tools(
746753
meta=meta,
747754
cache_mode=cache_mode,
748755
send=lambda: self.session.list_tools(params=PaginatedRequestParams(cursor=cursor, _meta=meta)),
749-
# A cache hit skips session.list_tools, so the session re-absorbs the
750-
# served listing to rebuild its derived per-tool state.
751-
absorb=self.session._absorb_tool_listing, # pyright: ignore[reportPrivateUsage]
756+
# A cache hit skips session.list_tools, so the session re-absorbs the served
757+
# listing to rebuild its derived per-tool state. Hits are cursorless, but a
758+
# cached page 1 can carry next_cursor - never prune on a partial listing.
759+
absorb=lambda hit: self.session._absorb_tool_listing( # pyright: ignore[reportPrivateUsage]
760+
hit, complete=hit.next_cursor is None
761+
),
752762
)
753763

754764
@deprecated("The roots capability is deprecated as of 2026-07-28 (SEP-2577).", category=MCPDeprecationWarning)

src/mcp/client/session.py

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -906,12 +906,14 @@ async def list_tools(self, *, params: types.PaginatedRequestParams | None = None
906906
types.ListToolsRequest(params=params),
907907
types.ListToolsResult,
908908
)
909-
return self._absorb_tool_listing(result)
909+
complete = (params is None or params.cursor is None) and result.next_cursor is None
910+
return self._absorb_tool_listing(result, complete=complete)
910911

911-
def _absorb_tool_listing(self, result: types.ListToolsResult) -> types.ListToolsResult:
912+
def _absorb_tool_listing(self, result: types.ListToolsResult, *, complete: bool) -> types.ListToolsResult:
912913
"""Filter the listing per the 2026 x-mcp-header MUST and rebuild derived per-tool state, in place.
913914
914915
Idempotent: cached values are already post-filter, so the response cache can re-absorb a served listing.
916+
`complete` (an uncursored single-page listing) prunes per-tool state down to the listing's tools.
915917
"""
916918
if self._negotiated_version in MODERN_PROTOCOL_VERSIONS:
917919
# 2026-07-28: clients MUST drop tools whose x-mcp-header annotations are invalid.
@@ -928,11 +930,17 @@ def _absorb_tool_listing(self, result: types.ListToolsResult) -> types.ListTools
928930
kept.append(tool)
929931
result.tools = kept
930932

931-
# Cache tool output schemas for future validation
932-
# Note: don't clear the cache, as we may be using a cursor
933+
# Cache tool output schemas for future validation; cursor pages only ever add.
933934
for tool in result.tools:
934935
self._tool_output_schemas[tool.name] = tool.output_schema
935936

937+
if complete:
938+
# The listing is the full tool universe, so state for unlisted tools is stale
939+
# (the server dropped them, or a shared-cache writer's filter did).
940+
names = {tool.name for tool in result.tools}
941+
self._x_mcp_header_maps = {k: v for k, v in self._x_mcp_header_maps.items() if k in names}
942+
self._tool_output_schemas = {k: v for k, v in self._tool_output_schemas.items() if k in names}
943+
936944
return result
937945

938946
@deprecated("The roots capability is deprecated as of 2026-07-28 (SEP-2577).", category=MCPDeprecationWarning)

tests/client/test_caching.py

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -432,6 +432,8 @@ async def clear(self) -> None:
432432
raise NotImplementedError
433433

434434

435+
# The lax pragmas here and in the wedged-store tests: 3.11's settrace-based coverage loses
436+
# tracing in frames resumed after the coordinator's bounded-shield cleanup cancellation.
435437
class _WedgingDeleteStore:
436438
"""Once `wedged` flips, every `delete` blocks forever (an Event nothing sets),
437439
modelling a remote store with no socket timeout of its own."""
@@ -449,7 +451,7 @@ async def get(self, key: CacheKey) -> CacheEntry | None:
449451

450452
async def set(self, key: CacheKey, entry: CacheEntry) -> None:
451453
await self.before_set_commits()
452-
await self.inner.set(key, entry)
454+
await self.inner.set(key, entry) # pragma: lax no cover
453455

454456
async def delete(self, key: CacheKey) -> None:
455457
self.deletes_started += 1
@@ -805,8 +807,10 @@ async def test_evict_key_with_a_wedged_store_delete_returns_at_the_cleanup_bound
805807
cache = _coordinator(store, store_cleanup_timeout=0.01)
806808
with caplog.at_level(logging.WARNING, logger="mcp.client.caching"), anyio.fail_after(5):
807809
await cache.evict_key("tools/list", "")
808-
assert store.deletes_started == 1 # the second arm's delete was abandoned with the first
809-
assert caplog.messages == snapshot(["Response cache store delete timed out; the entry will age out by TTL"])
810+
assert store.deletes_started == 1 # pragma: lax no cover # the second arm's delete was abandoned with the first
811+
assert caplog.messages == snapshot( # pragma: lax no cover
812+
["Response cache store delete timed out; the entry will age out by TTL"]
813+
)
810814

811815

812816
async def test_a_refresh_purge_with_a_wedged_store_delete_returns_at_the_cleanup_bound() -> None:
@@ -815,7 +819,7 @@ async def test_a_refresh_purge_with_a_wedged_store_delete_returns_at_the_cleanup
815819
gen = cache.capture("tools/list", "")
816820
with anyio.fail_after(5):
817821
await cache.write("tools/list", "", _wire_result(ttl_ms=0), gen, "refresh")
818-
assert store.deletes_started == 1
822+
assert store.deletes_started == 1 # pragma: lax no cover
819823

820824

821825
async def test_an_eviction_mid_set_with_a_wedged_store_delete_returns_at_the_cleanup_bound() -> None:
@@ -833,9 +837,9 @@ async def wedge_then_evict() -> None:
833837
with anyio.fail_after(5):
834838
await cache.write("tools/list", "", _wire_result(ttl_ms=60_000), gen, "use")
835839
# Opposite-arm delete, the eviction's first delete, the compensating delete.
836-
assert store.deletes_started == 3
840+
assert store.deletes_started == 3 # pragma: lax no cover
837841
# The accepted degradation: the unreaped entry stays until its TTL expires.
838-
assert await store.inner.get(CacheKey("tools/list", "", _private_arm())) is not None
842+
assert await store.inner.get(CacheKey("tools/list", "", _private_arm())) is not None # pragma: lax no cover
839843

840844

841845
# --- Coordinator: store error discipline ---

tests/client/test_client.py

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
509603
def 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

tests/client/test_client_caching.py

Lines changed: 40 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -139,16 +139,26 @@ def test_userinfo_variants_of_a_server_url_share_one_cache_identity() -> None:
139139
("HTTPS://a@X.example/mcp", "HTTPS://X.example/mcp"),
140140
("https://u@h/p?", "https://h/p?"),
141141
("https://u@h/p#", "https://h/p#"),
142+
("https://u\tser:p@h.example/p", "https://h.example/p"),
143+
("https://u:p@h.example/pa\tth", "https://h.example/pa\tth"),
142144
],
143-
ids=["scheme-case", "empty-query", "empty-fragment"],
145+
ids=["scheme-case", "empty-query", "empty-fragment", "tab-in-userinfo", "tab-in-path"],
144146
)
145147
def test_stripping_userinfo_changes_no_other_byte_of_the_url(with_userinfo: str, bare: str) -> None:
146148
"""The removed `userinfo@` is the only byte difference: no scheme case-folding, no dropped
147-
empty `?`/`#` delimiters. A userinfo-free URL passes through untouched, so arm equality
148-
proves the stripped form is byte-identical to the bare URL."""
149+
empty `?`/`#` delimiters, and control characters - which urlsplit would silently strip,
150+
misaligning any parser-derived slice - stay byte-exact outside the removed span. A
151+
userinfo-free URL passes through untouched, so arm equality proves the stripped form is
152+
byte-identical to the bare URL."""
149153
assert _private_arm(Client(with_userinfo)) == _private_arm(Client(bare))
150154

151155

156+
def test_a_url_without_an_authority_passes_through_unchanged() -> None:
157+
"""No `//` means no authority span, so an `@` elsewhere strips nothing."""
158+
arm_id = hashlib.sha256(b"mailto:a@b").hexdigest()
159+
assert _private_arm(Client("mailto:a@b")) == json.dumps(["private", None, arm_id, ""])
160+
161+
152162
def test_the_server_url_is_sha256_hashed_before_it_enters_key_material() -> None:
153163
"""Pins the docs' secrets-never-in-keys claim: a query-string secret never appears in store keys."""
154164
client = Client("https://user:pass@example.com/mcp?api_key=SECRET")
@@ -863,6 +873,33 @@ async def on_request(request: httpx.Request) -> None:
863873
assert posts[-1].headers["mcp-param-region"] == "us-west1"
864874

865875

876+
async def test_a_shared_store_hit_prunes_a_header_map_the_writers_filter_dropped() -> None:
877+
"""Cached listings are post-filter: when another client's refresh wrote a listing whose
878+
filter dropped tool `x` (its annotation went invalid), a hit on that entry must prune the
879+
reader's stale arg-to-header map, or it would keep emitting Mcp-Param-* headers for `x`."""
880+
valid = {"type": "object", "properties": {"region": {"type": "string", "x-mcp-header": "Region"}}}
881+
invalid = {"type": "object", "properties": {"region": {"type": "string", "x-mcp-header": "bad name"}}}
882+
schema = valid
883+
884+
async def list_tools(ctx: ServerRequestContext, params: types.PaginatedRequestParams | None) -> ListToolsResult:
885+
return ListToolsResult(tools=[Tool(name="x", input_schema=schema)])
886+
887+
server = Server("filtering", on_list_tools=list_tools, cache_hints={"tools/list": CacheHint(ttl_ms=60_000)})
888+
config = CacheConfig(store=InMemoryResponseCacheStore(), partition="p", target_id="svc", clock=_ManualClock())
889+
890+
with anyio.fail_after(5):
891+
async with Client(server, cache=config) as reader, Client(server, cache=config) as writer:
892+
await reader.list_tools() # fetches while `x` is valid; the reader holds its header map
893+
assert "x" in reader.session._x_mcp_header_maps
894+
895+
schema = invalid
896+
await writer.list_tools(cache_mode="refresh") # the writer's filter drops `x`; the entry is replaced
897+
898+
served = await reader.list_tools() # hit on the writer's entry
899+
assert served.tools == []
900+
assert "x" not in reader.session._x_mcp_header_maps
901+
902+
866903
async def test_a_tools_list_changed_notification_makes_the_next_list_refetch() -> None:
867904
"""Spec SHOULD: list_changed invalidates the cached listing. Legacy session +
868905
`default_ttl_ms` entry: eviction is era-independent."""

tests/interaction/transports/test_hosting_http_modern.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -511,7 +511,8 @@ async def test_modern_client_stops_mirroring_after_a_re_list_drops_the_tool() ->
511511
bad_schema = {"type": "object", "properties": {"a": {"type": "string", "x-mcp-header": "bad name"}}}
512512
valid = Tool(name="run", input_schema=schema)
513513
invalid = Tool(name="run", input_schema=bad_schema)
514-
listings = iter([valid, invalid])
514+
# Three pages: the call after the drop re-lists once because the prune also cleared `run`'s schema entry.
515+
listings = iter([valid, invalid, invalid])
515516

516517
async def list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult:
517518
return ListToolsResult(tools=[next(listings)], ttl_ms=0, cache_scope="public")

0 commit comments

Comments
 (0)