Skip to content

Commit af36ead

Browse files
committed
Strip userinfo textually and address review notes on the docs
1 parent ef7fdff commit af36ead

5 files changed

Lines changed: 25 additions & 7 deletions

File tree

docs/advanced/caching.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ One caveat on paginated lists: the protocol requires the **same `cacheScope` on
3939

4040
On a 2026-07-28 session, `Client` honors the hints for you: it has a built-in response cache, on by default. A result that arrives carrying a `ttlMs` is stored, and an identical call within that TTL is served from the cache with no round trip. A result that carries *no* hint is not cached: hint-less results get `CacheConfig.default_ttl_ms`, which defaults to `0` (immediately stale), so a server that declares nothing sees exactly the call-for-call traffic it always did.
4141

42-
```python title="client.py" hl_lines="33 35 38"
42+
```python title="client.py" hl_lines="34 36 39"
4343
--8<-- "docs_src/caching/tutorial003.py"
4444
```
4545

docs/migration.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -429,7 +429,7 @@ For protocol 2026-07-28 over Streamable HTTP, a tool's input-schema property may
429429

430430
### `Client` verbs may serve cached responses ([SEP-2549](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2549))
431431

432-
On protocol 2026-07-28, servers attach caching hints (`ttlMs`, `cacheScope`) to the cacheable results, and `Client` now honors them: `list_tools`, `list_prompts`, `list_resources`, `list_resource_templates`, and `read_resource` may serve a cached response instead of making a round trip, for as long as the server's `ttlMs` says the result is fresh. Servers that send no hints, including every pre-2026 server, see identical call-for-call behavior, because hint-less results are not cached. Pass `Client(..., cache=False)` to disable the cache and restore v1 behavior exactly; per-call control (`cache_mode`) and configuration (`CacheConfig`) are described in [Caching hints](advanced/caching.md).
432+
On protocol 2026-07-28, servers attach caching hints (`ttlMs`, `cacheScope`) to the cacheable results, and `Client` now honors them: `list_tools`, `list_prompts`, `list_resources`, `list_resource_templates`, and `read_resource` may serve a cached response instead of making a round trip, for as long as the server's `ttlMs` says the result is fresh. With the default configuration, servers that send no hints, including every pre-2026 server, see identical call-for-call behavior, because hint-less results are not cached (a `CacheConfig.default_ttl_ms` above zero caches them too). Pass `Client(..., cache=False)` to disable the cache and restore v1 behavior exactly; per-call control (`cache_mode`) and configuration (`CacheConfig`) are described in [Caching hints](advanced/caching.md).
433433

434434
### Server extensions API ([SEP-2133](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2133))
435435

docs_src/caching/tutorial003.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,10 +30,11 @@ async def list_tools(ctx: ServerRequestContext[Any], params: PaginatedRequestPar
3030

3131

3232
async def main() -> None:
33+
start = state.fetches
3334
async with Client(server, cache=CacheConfig(clock=lambda: state.now)) as client:
3435
await client.list_tools() # fetch 1
3536
await client.list_tools() # fresh for 60s: served from the cache
3637
state.now += 60.0
3738
await client.list_tools() # the TTL ran out: fetch 2
3839
await client.list_tools(cache_mode="refresh") # skip the cache read: fetch 3
39-
print(f"4 calls, {state.fetches} fetches")
40+
print(f"4 calls, {state.fetches - start} fetches")

src/mcp/client/client.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
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, urlunsplit
12+
from urllib.parse import urlsplit
1313

1414
import anyio
1515
import anyio.lowlevel
@@ -132,10 +132,11 @@ def _strip_userinfo(url: str) -> str:
132132
133133
Credentials must not enter cache-key material; any further normalization could merge distinct servers.
134134
"""
135-
parts = urlsplit(url)
136-
if "@" not in parts.netloc:
135+
netloc = urlsplit(url).netloc # raw authority bytes (urlsplit case-folds only `.scheme`), so slicing is exact
136+
if "@" not in netloc:
137137
return url
138-
return urlunsplit(parts._replace(netloc=parts.netloc.rpartition("@")[2]))
138+
start = url.index("//") + 2
139+
return url[:start] + netloc.rpartition("@")[2] + url[start + len(netloc) :]
139140

140141

141142
def _evicting_message_handler(cache: ClientResponseCache, user_handler: MessageHandlerFnT | None) -> MessageHandlerFnT:

tests/client/test_client_caching.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,22 @@ def test_userinfo_variants_of_a_server_url_share_one_cache_identity() -> None:
133133
assert _private_arm(bare) == _private_arm(with_password) == _private_arm(with_token)
134134

135135

136+
@pytest.mark.parametrize(
137+
("with_userinfo", "bare"),
138+
[
139+
("HTTPS://a@X.example/mcp", "HTTPS://X.example/mcp"),
140+
("https://u@h/p?", "https://h/p?"),
141+
("https://u@h/p#", "https://h/p#"),
142+
],
143+
ids=["scheme-case", "empty-query", "empty-fragment"],
144+
)
145+
def test_stripping_userinfo_changes_no_other_byte_of_the_url(with_userinfo: str, bare: str) -> None:
146+
"""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+
assert _private_arm(Client(with_userinfo)) == _private_arm(Client(bare))
150+
151+
136152
def test_the_server_url_is_sha256_hashed_before_it_enters_key_material() -> None:
137153
"""Pins the docs' secrets-never-in-keys claim: a query-string secret never appears in store keys."""
138154
client = Client("https://user:pass@example.com/mcp?api_key=SECRET")

0 commit comments

Comments
 (0)