From 93fe4e89e0272fb9fe3472b7ce363a6be0fc57e2 Mon Sep 17 00:00:00 2001
From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com>
Date: Thu, 9 Jul 2026 10:44:47 -0400
Subject: [PATCH] Document client extensions and mode=auto default
---
docs/clients/client.mdx | 39 ++++++++++++++++---
docs/development/v4-notes/change-register.mdx | 23 ++++++++++-
2 files changed, 55 insertions(+), 7 deletions(-)
diff --git a/docs/clients/client.mdx b/docs/clients/client.mdx
index 30e09e613..bc0219bd7 100644
--- a/docs/clients/client.mdx
+++ b/docs/clients/client.mdx
@@ -166,19 +166,19 @@ async with client:
MCP has two protocol eras: the original *legacy* era, which begins every connection with an `initialize` handshake, and the *modern* era (protocol version `2026-07-28` and later), which a client discovers by probing the server's `server/discover` endpoint. The `mode` parameter controls which era the client negotiates when it connects.
-By default, `mode="legacy"`. This runs the initialize handshake and behaves identically to earlier FastMCP versions, so existing code connecting to any server keeps working unchanged.
+By default, `mode="auto"`. The client probes `server/discover` and adopts the modern protocol when the server responds; for any server that is not positive evidence of modern support, it falls back to the legacy handshake. This makes the default safe against a mixed fleet of legacy and modern servers.
```python
from fastmcp import Client
-# Legacy handshake (the default)
-client = Client("https://example.com/mcp", mode="legacy")
+# Negotiate the newest era the server supports (the default)
+client = Client("https://example.com/mcp", mode="auto")
```
-Set `mode="auto"` to negotiate the newest era the server supports. The client probes `server/discover` and adopts the modern protocol when the server responds; for any server that is not positive evidence of modern support, it falls back to the legacy handshake. This makes `"auto"` safe to use against a mixed fleet of legacy and modern servers.
+Set `mode="legacy"` to force the initialize handshake. This behaves identically to earlier FastMCP versions and is the opt-out if a server misbehaves under discovery or you need the legacy `initialize` result object.
```python
-client = Client("https://example.com/mcp", mode="auto")
+client = Client("https://example.com/mcp", mode="legacy")
```
You can also pin a specific modern protocol version to adopt it directly, without a discovery probe:
@@ -196,7 +196,9 @@ async with Client("https://example.com/mcp", mode="auto") as client:
```
-`mode="auto"` is not the default yet — the conservative `"legacy"` remains the default to preserve byte-identical behavior against pre-2026 servers. Whether `"auto"` becomes the default is a future release decision.
+`mode="auto"` is the default as of FastMCP 4.0. Earlier versions defaulted to `"legacy"`. If a server behaves unexpectedly under discovery, or you depend on the legacy `initialize` result, pin the old behavior with `Client(..., mode="legacy")`.
+
+The SSE transport is legacy-only — it cannot carry the sessionless modern era — so a client connecting over SSE always negotiates the legacy handshake, even under `mode="auto"`.
## Response caching
@@ -234,6 +236,31 @@ async with client:
fresh = await client.list_tools_mcp(cache_mode="refresh")
```
+## Client extensions
+
+
+
+Client extensions (SEP-2133) are the advanced mechanism a client uses to opt into vendor capabilities that live outside the core protocol. An extension is a `ClientExtension` instance that bundles three things: a capability *advertisement* the server can read, one or more *result claims* that let the client parse extra `tools/call` result shapes, and *notification bindings* that observe server notifications the core protocol doesn't define. Pass a sequence of them to `extensions=`.
+
+```python
+from fastmcp import Client
+from myproject.extensions import AppsExtension
+
+client = Client("https://example.com/mcp", extensions=[AppsExtension()])
+```
+
+Each extension's contributions are threaded into the underlying session. Notification bindings compose with FastMCP's own internal task-status binding rather than replacing it, so an extension that observes a custom notification and FastMCP's task tracking both work on the same connection. Result claims and their advertisements are honored only on modern-era connections, so they are inert on a legacy handshake.
+
+For the rare case where you need to register additional result claims against an extension that is already advertised, pass them through `result_claims=`, keyed by the extension's identifier. Prefer declaring claims on the extension itself; this parameter merges extra claims with an extension's own.
+
+```python
+client = Client(
+ "https://example.com/mcp",
+ extensions=[AppsExtension()],
+ result_claims={"example.com/apps": [extra_claim]},
+)
+```
+
## Operations
FastMCP clients interact with three types of server components.
diff --git a/docs/development/v4-notes/change-register.mdx b/docs/development/v4-notes/change-register.mdx
index 6476d96cd..2ad00173a 100644
--- a/docs/development/v4-notes/change-register.mdx
+++ b/docs/development/v4-notes/change-register.mdx
@@ -160,7 +160,28 @@ Resource-not-found responses from the core `resources/read` handler previously u
## Client
-The `fastmcp.Client` public API is preserved exactly. The client stays a wrapper around `mcp.ClientSession` in legacy/handshake mode; the first-class `mcp.client.Client` is deliberately not adopted in this PR.
+The `fastmcp.Client` public API is largely preserved. The client stays a wrapper around `mcp.ClientSession`; the first-class `mcp.client.Client` is deliberately not adopted in this PR. Two client-surface changes are called out below: the connection `mode` default flips to `"auto"`, and `extensions=` / `result_claims=` are newly surfaced.
+
+### Connection `mode` defaults to `"auto"` — Breaking (behavior)
+
+`Client(mode=...)` now defaults to `"auto"` instead of `"legacy"`. The client probes `server/discover` and adopts the modern (`2026-07-28`) era when the server responds, denylist-falling-back to the initialize handshake for any server that is not positive evidence of a modern peer. Against a FastMCP server (which serves both eras), an ordinary `Client(url)` now negotiates the modern era by default, where the legacy-only Context push features are unavailable per the per-feature era matrix (see the *Protocol eras* section below) — server-initiated sampling/elicitation/roots, `ping`, session ids, and FastMCP task submission all require the legacy era. The one-line revert is `Client(..., mode="legacy")`, which restores byte-identical pre-v4 negotiation.
+
+The SSE transport is legacy-only (it cannot carry the sessionless modern era), so a client connecting over SSE negotiates the legacy handshake even under `mode="auto"` — expressed by a `ClientTransport.legacy_only` flag set on `SSETransport`.
+
+```python
+from fastmcp import Client
+
+client = Client("https://example.com/mcp") # now negotiates "auto"
+client = Client("https://example.com/mcp", mode="legacy") # opt back into the handshake
+```
+
+*Verify:* `fastmcp_slim/fastmcp/client/client.py` (`mode` default, `_negotiate` `legacy_only` shortcut), `fastmcp_slim/fastmcp/client/transports/{base,sse}.py` (`legacy_only`), `tests/client/client/test_mode_negotiation.py` (default, clean discover-rejection fallback, legacy-only transport), `docs/clients/client.mdx`.
+
+### `extensions=` / `result_claims=` surfaced — New (opt-in feature)
+
+`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. 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`, `new()`), `fastmcp_slim/fastmcp/client/transports/base.py` (`SessionKwargs.extensions`/`result_claims`), `tests/client/test_client_extensions.py` (fold, composition, live both-bindings-fire).
### Transports yield 2-tuples — Absorbed