55import hashlib
66import logging
77import uuid
8- from collections .abc import Awaitable , Callable , Mapping
8+ from collections .abc import Awaitable , Callable , Mapping , Sequence
99from contextlib import AsyncExitStack
1010from dataclasses import KW_ONLY , dataclass , field
1111from typing import Any , Literal , TypeVar , cast
3636 ReadResourceResult ,
3737 RequestParamsMeta ,
3838 ResourceTemplateReference ,
39+ Result ,
3940 ServerCapabilities ,
4041)
4142from mcp_types .version import HANDSHAKE_PROTOCOL_VERSIONS , MODERN_PROTOCOL_VERSIONS
4647from mcp .client ._probe import negotiate_auto
4748from mcp .client ._transport import Transport
4849from mcp .client .caching import CacheConfig , CacheMode , ClientResponseCache , InMemoryResponseCacheStore
50+ from mcp .client .extension import ClaimContext , ClientExtension , NotificationBinding , ResultClaim
4951from mcp .client .session import (
5052 ClientRequestContext ,
5153 ClientSession ,
6264from mcp .shared .direct_dispatcher import create_direct_dispatcher_pair
6365from mcp .shared .dispatcher import Dispatcher , ProgressFnT
6466from mcp .shared .exceptions import MCPDeprecationWarning , MCPError
67+ from mcp .shared .extension import validate_extension_identifier
6568from mcp .shared .jsonrpc_dispatcher import JSONRPCDispatcher
6669from 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
192259class 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