Skip to content

Commit 0f85f31

Browse files
committed
Fold ClientExtension instances into Client
Client.extensions becomes Sequence[ClientExtension] | None: instances are validated and read once at construction (identifier guard and grammar, duplicate identifiers, cross-extension claim and binding conflicts named by their owning extensions) and folded into the session as the capability ad, the claims-by-identifier mapping, and the binding list. call_tool resolves claimed shapes transparently: the retry closure allows claimed results through the multi-round-trip driver, the owning claim's resolver finishes the call, and non-error resolver products get the same output-schema revalidation as the direct path. The dict form of extensions= is replaced; advertise() covers ad-only uses, documented in the migration guide. No extensions means a session constructed byte-identically to before.
1 parent 414caef commit 0f85f31

14 files changed

Lines changed: 712 additions & 34 deletions

File tree

docs/migration.md

Lines changed: 36 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -469,11 +469,42 @@ extension handler can call `mcp.server.mcpserver.require_client_extension(ctx, i
469469
to reject a request with the `-32021` (missing required client capability) error
470470
when the client did not declare the extension.
471471

472-
Clients advertise extension support with the new `Client(extensions=...)` /
473-
`ClientSession(extensions=...)` argument, mirrored into `ClientCapabilities.extensions`.
474-
The extensions capability map is negotiated over `server/discover` (modern path);
475-
a legacy `initialize` handshake does not carry it. Extensions are off by default
476-
and never alter behaviour unless registered.
472+
On the client, `Client(extensions=...)` takes a sequence of
473+
`mcp.client.ClientExtension` instances. A client extension contributes its
474+
capability ad (mirrored into `ClientCapabilities.extensions`), its result
475+
claims (extra `tools/call` result shapes that `Client.call_tool` resolves
476+
transparently through the claim's resolver), and its notification bindings
477+
(handlers for vendor server notifications). The capability map rides
478+
`server/discover` and every modern request's `_meta` envelope; a legacy
479+
`initialize` handshake carries only the claim-less identifiers, since claimed
480+
result shapes cannot be delivered on a legacy wire. Extensions are off by
481+
default and never alter behaviour unless registered. (The low-level
482+
`ClientSession(extensions=...)` keeps the raw identifier-to-settings dict.)
483+
484+
Changed in the v2 pre-releases: earlier alphas took
485+
`Client(extensions={identifier: settings})`, an advertisement-only dict.
486+
Extensions now contribute behaviour — claims and notification handlers — not
487+
just an ad, and a sequence of declaration objects is the shape that can carry
488+
that. An ad-only entry becomes an `advertise()` call:
489+
490+
**Before (v2 alphas):**
491+
492+
```python
493+
client = Client(server, extensions={"com.example/ui": {"mimeTypes": [...]}})
494+
```
495+
496+
**After:**
497+
498+
```python
499+
from mcp.client import advertise
500+
501+
client = Client(server, extensions=[advertise("com.example/ui", {"mimeTypes": [...]})])
502+
```
503+
504+
`advertise()` is only for identifiers with no client-side behaviour.
505+
For a behavioural extension — e.g. tasks, once its extension ships — construct
506+
that extension's object instead; advertising an identifier you do not
507+
implement asserts wire support you don't have.
477508

478509
### `McpError` renamed to `MCPError`
479510

docs_src/apps/tutorial001.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from mcp import Client
2+
from mcp.client import advertise
23
from mcp.server.apps import APP_MIME_TYPE, EXTENSION_ID, Apps, client_supports_apps
34
from mcp.server.mcpserver import MCPServer
45
from mcp.server.mcpserver.context import Context
@@ -32,7 +33,7 @@ def get_time(ctx: Context) -> str:
3233

3334

3435
async def main() -> None:
35-
async with Client(mcp, extensions={EXTENSION_ID: {"mimeTypes": [APP_MIME_TYPE]}}) as client:
36+
async with Client(mcp, extensions=[advertise(EXTENSION_ID, {"mimeTypes": [APP_MIME_TYPE]})]) as client:
3637
result = await client.call_tool("get_time", {})
3738
print(result.content)
3839
# [TextContent(text='2026-06-26T12:00:00Z')]

docs_src/extensions/tutorial004.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from pydantic import Field
66

77
from mcp import Client
8+
from mcp.client import advertise
89
from mcp.server.context import ServerRequestContext
910
from mcp.server.extension import Extension, MethodBinding
1011
from mcp.server.mcpserver import MCPServer, require_client_extension
@@ -51,7 +52,7 @@ def methods(self) -> Sequence[MethodBinding]:
5152

5253

5354
async def main() -> None:
54-
async with Client(mcp, extensions={EXTENSION_ID: {}}) as client:
55+
async with Client(mcp, extensions=[advertise(EXTENSION_ID)]) as client:
5556
request = SearchRequest(params=SearchParams(query="mcp", limit=3))
5657
result = await client.session.send_request(request, SearchResult)
5758
print(result.items)

examples/stories/apps/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ uv run python -m stories.apps.client --http
2626
`text/html;profile=mcp-app`.
2727
- `server.py` `client_supports_apps(ctx)` — SEP-2133 graceful degradation: a
2828
client that did not negotiate Apps gets a text-only result.
29-
- `client.py` `Client(target, extensions={...})` — the client advertises Apps
29+
- `client.py` `Client(target, extensions=[advertise(...)])` — the client advertises Apps
3030
support so the server returns the UI-enabled result, then reads the tool's
3131
`_meta.ui.resourceUri` and fetches that resource.
3232

examples/stories/apps/client.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,17 @@
22

33
from mcp_types import TextContent, TextResourceContents
44

5-
from mcp.client import Client
5+
from mcp.client import Client, advertise
66
from mcp.server.apps import APP_MIME_TYPE, EXTENSION_ID
77
from stories._harness import Target, run_client
88

99

1010
async def main(target: Target, *, mode: str = "auto") -> None:
1111
# Advertise MCP Apps support so the server returns the UI-enabled result; a
1212
# client that omits this gets the text-only fallback (graceful degradation).
13-
async with Client(target, mode=mode, extensions={EXTENSION_ID: {"mimeTypes": [APP_MIME_TYPE]}}) as client:
13+
async with Client(
14+
target, mode=mode, extensions=[advertise(EXTENSION_ID, {"mimeTypes": [APP_MIME_TYPE]})]
15+
) as client:
1416
# The extensions capability map rides `server/discover` (modern only). On a
1517
# legacy connection (today's stdio) it is absent, so assert it only when present.
1618
if client.server_capabilities.extensions is not None:

examples/stories/extensions/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ uv run python -m stories.extensions.client --http
2424
rejects clients that did not declare the extension with `-32021` (missing
2525
required client capability) and a machine-readable `requiredCapabilities`
2626
payload.
27-
- `client.py` `Client(target, extensions={EXTENSION_ID: {}})` — the client-side
27+
- `client.py` `Client(target, extensions=[advertise(EXTENSION_ID)])` — the client-side
2828
half of the negotiation; on 2026-07-28 it travels in the per-request `_meta`
2929
envelope.
3030
- `client.py` `client.session.send_request(...)` — vendor methods have no

examples/stories/extensions/client.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
import mcp_types as types
66
from mcp_types import TextContent
77

8-
from mcp.client import Client
8+
from mcp.client import Client, advertise
99
from stories._harness import Target, run_client
1010

1111
EXTENSION_ID = "com.example/catalog"
@@ -28,7 +28,7 @@ class SearchResult(types.Result):
2828
async def main(target: Target, *, mode: str = "auto") -> None:
2929
# Declare the extension client-side so the server's `require_client_extension`
3030
# gate on `com.example/search` passes.
31-
async with Client(target, mode=mode, extensions={EXTENSION_ID: {}}) as client:
31+
async with Client(target, mode=mode, extensions=[advertise(EXTENSION_ID)]) as client:
3232
# The extensions capability map rides `server/discover` (modern only). On a
3333
# legacy connection it is absent, so assert it only when present.
3434
if client.server_capabilities.extensions is not None:

src/mcp/client/client.py

Lines changed: 112 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
import hashlib
66
import logging
77
import uuid
8-
from collections.abc import Awaitable, Callable, Mapping
8+
from collections.abc import Awaitable, Callable, Mapping, Sequence
99
from contextlib import AsyncExitStack
1010
from dataclasses import KW_ONLY, dataclass, field
1111
from typing import Any, Literal, TypeVar, cast
@@ -36,6 +36,7 @@
3636
ReadResourceResult,
3737
RequestParamsMeta,
3838
ResourceTemplateReference,
39+
Result,
3940
ServerCapabilities,
4041
)
4142
from mcp_types.version import HANDSHAKE_PROTOCOL_VERSIONS, MODERN_PROTOCOL_VERSIONS
@@ -46,6 +47,7 @@
4647
from mcp.client._probe import negotiate_auto
4748
from mcp.client._transport import Transport
4849
from mcp.client.caching import CacheConfig, CacheMode, ClientResponseCache, InMemoryResponseCacheStore
50+
from mcp.client.extension import ClaimContext, ClientExtension, NotificationBinding, ResultClaim
4951
from mcp.client.session import (
5052
ClientRequestContext,
5153
ClientSession,
@@ -62,6 +64,7 @@
6264
from mcp.shared.direct_dispatcher import create_direct_dispatcher_pair
6365
from mcp.shared.dispatcher import Dispatcher, ProgressFnT
6466
from mcp.shared.exceptions import MCPDeprecationWarning, MCPError
67+
from mcp.shared.extension import validate_extension_identifier
6568
from mcp.shared.jsonrpc_dispatcher import JSONRPCDispatcher
6669
from mcp.shared.session import RequestResponder
6770

@@ -188,6 +191,70 @@ async def _no_inbound_client_notifications(_dctx: Any, _method: str, _params: Ma
188191
"""
189192

190193

194+
@dataclass(frozen=True)
195+
class _FoldedExtensions:
196+
"""`Client.extensions` instances folded into the shapes `ClientSession` consumes."""
197+
198+
ad: dict[str, dict[str, Any]] | None
199+
claims: dict[str, tuple[ResultClaim[Any], ...]] | None
200+
bindings: tuple[NotificationBinding[Any], ...] | None
201+
by_model: Mapping[type[Result], ResultClaim[Any]]
202+
203+
204+
def _fold_extensions(extensions: Sequence[ClientExtension] | None) -> _FoldedExtensions:
205+
"""Validate extension instances and fold their contributions, once, at `Client` construction.
206+
207+
Mirrors the server's consumption-time posture (`MCPServer._apply_extension`): a
208+
per-instance identifier is validated here because no class attribute existed to
209+
validate at definition time. `settings()` is read exactly once per extension and
210+
the returned dict is held by reference. Duplicate `(method, resultType)` claims
211+
and duplicate notification methods are rejected here, where both owning extensions
212+
can be named — the session's own duplicate checks know only methods and tags.
213+
"""
214+
if not extensions:
215+
return _FoldedExtensions(ad=None, claims=None, bindings=None, by_model={})
216+
ad: dict[str, dict[str, Any]] = {}
217+
claims: dict[str, tuple[ResultClaim[Any], ...]] = {}
218+
bindings: list[NotificationBinding[Any]] = []
219+
by_model: dict[type[Result], ResultClaim[Any]] = {}
220+
claim_owners: dict[tuple[str, str], str] = {}
221+
binding_owners: dict[str, str] = {}
222+
for extension in extensions:
223+
identifier = getattr(extension, "identifier", None)
224+
if identifier is None:
225+
raise ValueError(
226+
f"{type(extension).__name__} has no `identifier`; a ClientExtension must set the "
227+
"`identifier` class attribute (or assign one in `__init__`) before it can be used"
228+
)
229+
validate_extension_identifier(identifier, owner=type(extension).__name__)
230+
if identifier in ad:
231+
raise ValueError(f"extension identifier {identifier!r} is passed more than once")
232+
ad[identifier] = extension.settings()
233+
extension_claims = tuple(extension.claims())
234+
for claim in extension_claims:
235+
key = (claim.method, claim.result_type)
236+
if key in claim_owners:
237+
raise ValueError(
238+
f"extensions {claim_owners[key]!r} and {identifier!r} both claim {claim.method!r} "
239+
f"resultType {claim.result_type!r}; a wire tag can have only one resolver"
240+
)
241+
claim_owners[key] = identifier
242+
# Collision-free by construction: a model's `result_type` Literal pins it to
243+
# exactly one tag, and each (method, tag) pair has exactly one owner.
244+
by_model[claim.model] = claim
245+
if extension_claims:
246+
claims[identifier] = extension_claims
247+
for binding in extension.notifications():
248+
if binding.method in binding_owners:
249+
raise ValueError(
250+
f"extensions {binding_owners[binding.method]!r} and {identifier!r} both bind "
251+
f"notification method {binding.method!r}; a method can have only one observer"
252+
)
253+
binding_owners[binding.method] = identifier
254+
bindings.append(binding)
255+
return _FoldedExtensions(ad=ad, claims=claims or None, bindings=tuple(bindings) or None, by_model=by_model)
256+
257+
191258
@dataclass
192259
class Client:
193260
"""A high-level MCP client for connecting to MCP servers.
@@ -268,9 +335,16 @@ async def main():
268335
`read_resource` give up. Use `client.session.<method>(..., allow_input_required=True)`
269336
to drive the loop manually instead."""
270337

271-
extensions: dict[str, dict[str, Any]] | None = None
272-
"""SEP-2133 extension support to advertise under `ClientCapabilities.extensions`
273-
(identifier -> settings), e.g. `{"io.modelcontextprotocol/ui": {"mimeTypes": [...]}}`."""
338+
extensions: Sequence[ClientExtension] | None = None
339+
"""Opt-in client extensions (SEP-2133).
340+
341+
Each instance contributes its capability ad (advertised under
342+
`ClientCapabilities.extensions`), its result claims (extra `tools/call` result
343+
shapes that `call_tool` resolves transparently through the claim's resolver),
344+
and its notification bindings. For an ad-only entry — an identifier plus
345+
settings, no client-side behaviour — use `mcp.client.advertise(identifier,
346+
settings)`. Each extension's `settings()` is read once, at construction; the
347+
returned dict is held by reference."""
274348

275349
cache: CacheConfig | Literal[False] | None = None
276350
"""Client-side response caching for the SEP-2549 cacheable methods (2026-07-28).
@@ -286,6 +360,7 @@ async def main():
286360
_exit_stack: AsyncExitStack | None = field(init=False, default=None)
287361
_connect: _Connector = field(init=False, repr=False, compare=False)
288362
_response_cache: ClientResponseCache | None = field(init=False, default=None, repr=False, compare=False)
363+
_folded_extensions: _FoldedExtensions = field(init=False, repr=False, compare=False)
289364

290365
def __post_init__(self) -> None:
291366
if self.mode not in ("legacy", "auto") and self.mode not in MODERN_PROTOCOL_VERSIONS:
@@ -298,6 +373,8 @@ def __post_init__(self) -> None:
298373
f"mode must be 'legacy', 'auto', or one of {list(MODERN_PROTOCOL_VERSIONS)}; got {self.mode!r}{hint}"
299374
)
300375

376+
self._folded_extensions = _fold_extensions(self.extensions)
377+
301378
srv = self.server
302379
if isinstance(srv, MCPServer):
303380
srv = srv._lowlevel_server # pyright: ignore[reportPrivateUsage]
@@ -348,7 +425,9 @@ async def _build_session(self, exit_stack: AsyncExitStack) -> ClientSession:
348425
message_handler=message_handler,
349426
client_info=self.client_info,
350427
elicitation_callback=self.elicitation_callback,
351-
extensions=self.extensions,
428+
extensions=self._folded_extensions.ad,
429+
result_claims=self._folded_extensions.claims,
430+
notification_bindings=self._folded_extensions.bindings,
352431
)
353432

354433
async def __aenter__(self) -> Client:
@@ -611,6 +690,13 @@ async def call_tool(
611690
persist `request_state` across process restarts — use
612691
`client.session.call_tool(..., allow_input_required=True)`.
613692
693+
If the server returns a result shape claimed by one of this client's
694+
`extensions`, the owning claim's resolver finishes the call and its
695+
`CallToolResult` is returned — the claimed shape never surfaces here.
696+
Resolver exceptions propagate as-is; the extension owns its error
697+
vocabulary. To receive the claimed shape yourself, use
698+
`client.session.call_tool(..., allow_claimed=True)`.
699+
614700
Args:
615701
name: The name of the tool to call.
616702
arguments: Arguments to pass to the tool.
@@ -629,7 +715,7 @@ async def call_tool(
629715
MCPError: A callback returned `ErrorData` for an embedded input request.
630716
"""
631717

632-
async def retry(r: InputResponses | None, s: str | None) -> CallToolResult | InputRequiredResult:
718+
async def retry(r: InputResponses | None, s: str | None) -> CallToolResult | InputRequiredResult | Result:
633719
return await self.session.call_tool(
634720
name,
635721
arguments,
@@ -639,9 +725,28 @@ async def retry(r: InputResponses | None, s: str | None) -> CallToolResult | Inp
639725
request_state=s,
640726
meta=meta,
641727
allow_input_required=True,
728+
# The driver's retry leg must also admit claimed shapes — the spec
729+
# resolves multi-round-trip input before a claimed result, so a claim
730+
# may terminate any round, not just the first.
731+
allow_claimed=True,
642732
)
643733

644-
return await self._drive_input_required(await retry(input_responses, request_state), retry)
734+
result = await self._drive_input_required(await retry(input_responses, request_state), retry)
735+
if isinstance(result, CallToolResult):
736+
return result
737+
# Only claimed shapes escape the parse (`_drive_input_required` never returns an
738+
# `InputRequiredResult`), so the lookup is total; a KeyError here is an SDK bug.
739+
claim = self._folded_extensions.by_model[type(result)]
740+
final = await claim.resolve(
741+
result,
742+
ClaimContext(session=self.session, tool_name=name, read_timeout_seconds=read_timeout_seconds),
743+
)
744+
if not final.is_error:
745+
# The resolver's product gets the same output-schema revalidation as the
746+
# direct path (`ClientSession.call_tool`'s own guard); isError results
747+
# must not raise, also matching the direct path.
748+
await self.session.validate_tool_result(name, final)
749+
return final
645750

646751
async def list_prompts(
647752
self,

0 commit comments

Comments
 (0)