Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion docs/development/v4-notes/change-register.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -209,7 +209,15 @@ client = Client("https://example.com/mcp", mode="legacy") # opt back into the

`fastmcp.Client` now accepts `extensions=` (a sequence of SEP-2133 `ClientExtension` instances) and `result_claims=` (extra `ResultClaim`s keyed by an advertised extension's identifier). Each extension's capability advertisement, result claims, and notification bindings are folded into the underlying `ClientSession` on every transport. User-supplied notification bindings **compose** with FastMCP's internal task-status binding rather than clobbering it: the task binding always leads, and a user extension that binds the same method surfaces a clear duplicate-method error at connect time rather than silently winning. Result claims are wired end-to-end: `call_tool()` / `call_tool_mcp()` pass `allow_claimed=True` and resolve a claimed result through the owning claim's resolver (`ClaimContext`), so a server-emitted claimed shape is finished into an ordinary `CallToolResult` instead of raising `UnexpectedClaimedResult`. Claimed shapes are modern-only, so they are inert on a legacy connection.

*Verify:* `fastmcp_slim/fastmcp/client/client.py` (`_fold_extensions`, `_build_extension_kwargs`, `_resolve_claimed_result`, `new()`), `fastmcp_slim/fastmcp/client/mixins/tools.py` (`call_tool_mcp` claim resolution), `fastmcp_slim/fastmcp/client/transports/base.py` (`SessionKwargs.extensions`/`result_claims`), `tests/client/test_client_extensions.py` (fold, composition, live both-bindings-fire, end-to-end claim resolution).
*Verify:* `fastmcp_slim/fastmcp/client/client.py` (`_build_extension_kwargs`, `_resolve_claimed_result`, `new()`), `fastmcp_slim/fastmcp/client/mixins/tools.py` (`call_tool_mcp` claim resolution), `fastmcp_slim/fastmcp/client/transports/base.py` (`SessionKwargs.extensions`/`result_claims`), `tests/client/test_client_extensions.py` (fold, composition, live both-bindings-fire, end-to-end claim resolution).

### Protocol helpers delegated to the SDK — Absorbed (internal)

`fastmcp.Client` carried forked copies of three SDK helpers — `_fold_extensions` (with its `_FoldedExtensions` dataclass), `_evicting_message_handler`, and `_synthesize_discover` — written when the SDK had not yet stabilized them. It now imports the SDK's implementations directly. The forks had already drifted: FastMCP's `_fold_extensions` was missing the SEP-2133 `validate_extension_identifier` check, so a non-reverse-DNS extension identifier that the SDK rejects was silently accepted. Adopting the SDK's version closes that gap. No public surface moves; the SDK returns `None` rather than empty collections for the folded claims and bindings, absorbed at the two call sites in `_build_extension_kwargs`.

Full composition — `fastmcp.Client` holding an `mcp.Client` and delegating the connection lifecycle to it — remains blocked upstream. `mcp.Client._build_session` hardcodes `ClientSession(...)` with no override hook, but FastMCP's `TransportOptions.session_class` is load-bearing: `ProxyClient` supplies a `_ForwardingClientSession` that skips output-schema validation so a backend's schema bug surfaces at the end client rather than as a proxy error. Separately, `mcp.Client.__aenter__` raises on reentry, while FastMCP's refcounted reentrant context manager is depended on by proxy session reuse. Both would need an upstream `session_factory=` hook (the same shape as the `notification_bindings=` ask that unblocked extension composition) before the lifecycle itself can be delegated.

*Verify:* `fastmcp_slim/fastmcp/client/client.py` (imports from `mcp.client.client`; no local helper definitions), `fastmcp_slim/fastmcp/client/transports/base.py` (`TransportOptions.session_class`), `fastmcp_slim/fastmcp/server/providers/proxy.py` (`_ForwardingClientSession`, `PROXY_TRANSPORT_OPTIONS`).

### Transports yield 2-tuples — Absorbed

Expand Down
25 changes: 7 additions & 18 deletions fastmcp_slim/fastmcp/client/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,11 @@
ClientResponseCache,
InMemoryResponseCacheStore,
)
from mcp.client.client import (
_evicting_message_handler,
_fold_extensions,
Comment on lines +35 to +36

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Remove the local helpers that shadow these imports

These imports are rebound by the module-level _fold_extensions and _evicting_message_handler definitions at lines 226 and 295, so all call sites still use FastMCP's forked copies. In particular, Client(extensions=[...]) continues through the local folding function, which never calls validate_extension_identifier, and therefore still accepts identifiers such as "foo" despite this change's intended validation tightening; remove the local helper definitions (and _FoldedExtensions) so the SDK implementations are actually used.

Useful? React with 👍 / 👎.

_synthesize_discover,
)
from mcp.client.extension import (
ClaimContext,
ClientExtension,
Expand Down Expand Up @@ -139,22 +144,6 @@
"""


def _synthesize_discover(protocol_version: str) -> mcp_types.DiscoverResult:
"""Build a minimal ``DiscoverResult`` for a pinned modern version (no wire probe).

Mirrors the SDK Client's ``_synthesize_discover``: the version is pinned but the
server identity is unknown, so ``server_info`` is empty.
"""
return mcp_types.DiscoverResult(
supported_versions=[protocol_version],
capabilities=mcp_types.ServerCapabilities(),
server_info=mcp_types.Implementation(name="", version=""),
result_type="complete",
ttl_ms=0,
cache_scope="public",
)


@asynccontextmanager
async def _conformant_discover_only(
session: ClientSession,
Expand Down Expand Up @@ -1370,7 +1359,7 @@ def _build_extension_kwargs(self) -> SessionKwargs:
"""
folded = _fold_extensions(self._extensions_arg)

claims: dict[str, tuple[ResultClaim[Any], ...]] = dict(folded.claims)
claims: dict[str, tuple[ResultClaim[Any], ...]] = dict(folded.claims or {})
by_model: dict[type[mcp_types.Result], ResultClaim[Any]] = dict(folded.by_model)
for identifier, extra in (self._result_claims_arg or {}).items():
existing = claims.get(identifier, ())
Expand All @@ -1383,7 +1372,7 @@ def _build_extension_kwargs(self) -> SessionKwargs:
# The internal task binding must lead so user bindings extend it.
"notification_bindings": [
self._task_status_binding(),
*folded.bindings,
*(folded.bindings or ()),
],
}
if folded.ad:
Expand Down
Loading