Skip to content

Commit 2bcfae3

Browse files
authored
Merge pull request #4572 from PrefectHQ/feature/client-auto-default
Negotiate the best mutual protocol era by default
2 parents 57bbc98 + afbe342 commit 2bcfae3

100 files changed

Lines changed: 1997 additions & 583 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

docs/clients/auth/bearer.mdx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ async with Client(
3737
"https://your-server.fastmcp.app/mcp",
3838
auth="<your-token>",
3939
) as client:
40-
await client.ping()
40+
await client.list_tools()
4141
```
4242

4343
You can also supply a Bearer token to a transport instance, such as `StreamableHttpTransport` or `SSETransport`:
@@ -52,7 +52,7 @@ transport = StreamableHttpTransport(
5252
)
5353

5454
async with Client(transport) as client:
55-
await client.ping()
55+
await client.list_tools()
5656
```
5757

5858
## `BearerAuth` Helper
@@ -67,7 +67,7 @@ async with Client(
6767
"https://your-server.fastmcp.app/mcp",
6868
auth=BearerAuth(token="<your-token>"),
6969
) as client:
70-
await client.ping()
70+
await client.list_tools()
7171
```
7272

7373
## Custom Headers
@@ -84,5 +84,5 @@ async with Client(
8484
headers={"X-API-Key": "<your-token>"},
8585
),
8686
) as client:
87-
await client.ping()
87+
await client.list_tools()
8888
```

docs/clients/auth/cimd.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ async with Client(
3232
client_metadata_url="https://myapp.example.com/oauth/client.json",
3333
),
3434
) as client:
35-
await client.ping()
35+
await client.list_tools()
3636
```
3737

3838
When the server supports CIMD, the client uses your metadata URL as its `client_id` instead of performing Dynamic Client Registration. The server fetches your document, validates it, and proceeds with the standard OAuth authorization flow.

docs/clients/auth/oauth.mdx

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ from fastmcp import Client
2929

3030
# Uses default OAuth settings
3131
async with Client("https://your-server.fastmcp.app/mcp", auth="oauth") as client:
32-
await client.ping()
32+
await client.list_tools()
3333
```
3434

3535

@@ -44,7 +44,7 @@ from fastmcp.client.auth import OAuth
4444
oauth = OAuth(scopes=["user"])
4545

4646
async with Client("https://your-server.fastmcp.app/mcp", auth=oauth) as client:
47-
await client.ping()
47+
await client.list_tools()
4848
```
4949

5050
<Note>
@@ -125,7 +125,7 @@ encrypted_storage = FernetEncryptionWrapper(
125125
oauth = OAuth(token_storage=encrypted_storage)
126126

127127
async with Client("https://your-server.fastmcp.app/mcp", auth=oauth) as client:
128-
await client.ping()
128+
await client.list_tools()
129129
```
130130

131131
You can use any `AsyncKeyValue`-compatible backend from the [key-value library](https://github.com/strawgate/py-key-value) including Redis, DynamoDB, and more. Wrap your storage in `FernetEncryptionWrapper` for encryption.
@@ -150,7 +150,7 @@ async with Client(
150150
client_metadata_url="https://myapp.example.com/oauth/client.json",
151151
),
152152
) as client:
153-
await client.ping()
153+
await client.list_tools()
154154
```
155155

156156
See the [CIMD Authentication](/clients/auth/cimd) page for complete documentation on creating, hosting, and validating CIMD documents.
@@ -172,7 +172,7 @@ async with Client(
172172
client_secret="my-client-secret",
173173
),
174174
) as client:
175-
await client.ping()
175+
await client.list_tools()
176176
```
177177

178178
Public clients that rely on PKCE for security can omit `client_secret`:

docs/clients/client.mdx

Lines changed: 46 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -37,9 +37,6 @@ client = Client("my_mcp_server.py")
3737

3838
async def main():
3939
async with client:
40-
# Basic server interaction
41-
await client.ping()
42-
4340
# List available operations
4441
tools = await client.list_tools()
4542
resources = await client.list_resources()
@@ -171,21 +168,31 @@ async with client:
171168

172169
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.
173170

174-
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.
171+
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.
175172

176173
```python
177174
from fastmcp import Client
178175

179-
# Legacy handshake (the default)
180-
client = Client("https://example.com/mcp", mode="legacy")
176+
# Negotiate the newest era the server supports (the default)
177+
client = Client("https://example.com/mcp", mode="auto")
181178
```
182179

183-
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.
180+
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.
184181

185182
```python
186-
client = Client("https://example.com/mcp", mode="auto")
183+
client = Client("https://example.com/mcp", mode="legacy")
187184
```
188185

186+
Legacy mode is also what you need for the capabilities that depend on a live session between client and server. The handshake opens a persistent back-channel the server can push requests down, and the modern era removed it. Pin `mode="legacy"` when your code relies on any of these:
187+
188+
- **[Sampling](/clients/sampling)** — server-initiated LLM completion requests
189+
- **[Roots](/clients/roots)** — server-initiated requests for the client's roots
190+
- **[Elicitation](/clients/elicitation)** — server-initiated requests for user input, which modern connections replace with [input-required rounds](/clients/elicitation#input-required-rounds)
191+
- **[Background tasks](/clients/tasks)** — submitting an operation with `task=True`
192+
- `client.ping()` and `transport.get_session_id()`
193+
194+
A FastMCP server serves both eras, so a default client negotiates the modern one and these raise an era-specific error. Pinning the handshake restores them.
195+
189196
You can also pin a specific modern protocol version to adopt it directly, without a discovery probe:
190197

191198
```python
@@ -201,7 +208,9 @@ async with Client("https://example.com/mcp", mode="auto") as client:
201208
```
202209

203210
<Note>
204-
`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.
211+
`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")`.
212+
213+
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"`. A multi-server config (`MCPConfigTransport` with more than one server) is likewise legacy-only, because it mounts each backend behind a legacy-era proxy; a single-server config mirrors its one backend transport's era.
205214
</Note>
206215

207216
## Response caching
@@ -260,6 +269,31 @@ client = Client("https://example.com/mcp", mode="auto", cache=config)
260269

261270
The adapter serializes each result through a type-tagged envelope validated against an allowlist of cacheable result models, so a value naming an unknown type is treated as a cache miss rather than deserialized blindly. Each store instance owns its own collection namespace; `clear()` affects only that namespace, never another tenant's entries.
262271

272+
## Client extensions
273+
274+
<VersionBadge version="4.0.0" />
275+
276+
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=`.
277+
278+
```python
279+
from fastmcp import Client
280+
from myproject.extensions import AppsExtension
281+
282+
client = Client("https://example.com/mcp", extensions=[AppsExtension()])
283+
```
284+
285+
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. When a tool returns a shape an extension claims, `client.call_tool()` resolves it transparently through the owning claim's resolver and hands you back an ordinary result. Result claims and their advertisements are honored only on modern-era connections, so they are inert on a legacy handshake.
286+
287+
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.
288+
289+
```python
290+
client = Client(
291+
"https://example.com/mcp",
292+
extensions=[AppsExtension()],
293+
result_claims={"example.com/apps": [extra_claim]},
294+
)
295+
```
296+
263297
## Operations
264298

265299
FastMCP clients interact with three types of server components.
@@ -301,6 +335,8 @@ See [Prompts](/clients/prompts) for detailed documentation including argument se
301335

302336
The client supports callback handlers for advanced server interactions. These let you respond to server-initiated requests and receive notifications.
303337

338+
Sampling, elicitation, and roots are all server-initiated, so they belong to the handshake era described under [protocol negotiation](#protocol-negotiation). A default client negotiates the newest era both peers share, where the server has no back-channel to push those requests down, so an example that exercises them pins `mode="legacy"`. Logging and progress arrive as notifications on the response stream and work in either era.
339+
304340
```python
305341
from fastmcp import Client
306342
from fastmcp.client.logging import LogMessage
@@ -317,6 +353,7 @@ async def sampling_handler(messages, params, context):
317353

318354
client = Client(
319355
"my_mcp_server.py",
356+
mode="legacy",
320357
log_handler=log_handler,
321358
progress_handler=progress_handler,
322359
sampling_handler=sampling_handler,

docs/clients/elicitation.mdx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,12 @@ Use this when you need to respond to server requests for user input during tool
1313

1414
Elicitation allows MCP servers to request structured input from users during operations. Instead of requiring all inputs upfront, servers can interactively ask for missing parameters, request clarification, or gather additional context.
1515

16+
Two routes reach that outcome, and the protocol version the client negotiates decides which one applies. On older versions the server pushes an elicitation request down to the client, over the connection the `initialize` handshake opens; that is the flow the next few sections describe. On `2026-07-28` and later the server instead returns a description of what it needs, and the client answers with a fresh call — see [input-required rounds](#input-required-rounds). You write the same `elicitation_handler` either way — FastMCP routes it to whichever mechanism the connection supports.
17+
18+
<Note>
19+
**This page shows the older protocol's elicitation flow.** On protocol version `2026-07-28` the server instead returns a description of what it needs and the client answers with a new call — see [input-required rounds](#input-required-rounds). The same `elicitation_handler` serves both. Clients default to `mode="auto"`, so the examples below pass `mode="legacy"` to exercise the server-initiated flow. See [protocol negotiation](/clients/client#protocol-negotiation).
20+
</Note>
21+
1622
## Handler Template
1723

1824
```python
@@ -54,6 +60,7 @@ async def elicitation_handler(
5460

5561
client = Client(
5662
"my_mcp_server.py",
63+
mode="legacy",
5764
elicitation_handler=elicitation_handler,
5865
)
5966
```
@@ -138,6 +145,7 @@ async def elicitation_handler(message, response_type, params, context):
138145

139146
client = Client(
140147
"my_mcp_server.py",
148+
mode="legacy",
141149
elicitation_handler=elicitation_handler
142150
)
143151
```
@@ -146,7 +154,7 @@ client = Client(
146154

147155
<VersionBadge version="4.0.0" />
148156

149-
On modern-era connections (protocol version `2026-07-28` and later), a server can ask for input before it returns a final result. Nothing is held open: the tool *returns* a description of what it needs, which completes that round as an ordinary response, and the client answers by issuing a **new** `call_tool`, `get_prompt`, or `read_resource` request carrying the answer. `fastmcp.Client` drives that loop for you — it fulfils each round's requests using the callbacks you already configured (your `elicitation_handler`, `sampling_handler`, and roots) and repeats until the call reaches a terminal result. No extra wiring is needed beyond the handlers described above.
157+
On protocol version `2026-07-28` and later, a server can ask for input before it returns a final result. Nothing is held open: the tool *returns* a description of what it needs, which completes that round as an ordinary response, and the client answers by issuing a **new** `call_tool`, `get_prompt`, or `read_resource` request carrying the answer. `fastmcp.Client` drives that loop for you — it fulfils each round's requests using the callbacks you already configured (your `elicitation_handler`, `sampling_handler`, and roots) and repeats until the call reaches a terminal result. No extra wiring is needed beyond the handlers described above.
150158

151159
The `input_required_max_rounds` parameter caps how many rounds the client will answer before giving up, guarding against a server that never terminates. It defaults to `10`.
152160

docs/clients/roots.mdx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,10 @@ Use this when you need to tell servers what local resources the client has acces
1313

1414
Roots inform servers about resources the client can provide. Servers can use this information to adjust behavior or provide more relevant responses.
1515

16+
<Note>
17+
**Roots require the older MCP protocol.** A server reads roots by sending a request down to the client, and protocol version `2026-07-28` removed the server's ability to do that. Clients default to `mode="auto"`, which negotiates the newest version both sides support, so the examples below pass `mode="legacy"`. See [protocol negotiation](/clients/client#protocol-negotiation).
18+
</Note>
19+
1620
## Static Roots
1721

1822
Provide a list of roots when creating the client:
@@ -22,6 +26,7 @@ from fastmcp import Client
2226

2327
client = Client(
2428
"my_mcp_server.py",
29+
mode="legacy",
2530
roots=["/path/to/root1", "/path/to/root2"]
2631
)
2732
```
@@ -40,6 +45,7 @@ async def roots_callback(context: RequestContext) -> list[str]:
4045

4146
client = Client(
4247
"my_mcp_server.py",
48+
mode="legacy",
4349
roots=roots_callback
4450
)
4551
```

docs/clients/sampling.mdx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,10 @@ Use this when you need to respond to server requests for LLM completions.
1313

1414
MCP servers can request LLM completions from clients during tool execution. This enables servers to delegate AI reasoning to the client, which controls which LLM is used and how requests are made.
1515

16+
<Note>
17+
**Sampling requires the older MCP protocol.** A server requests sampling by sending a request down to the client, and protocol version `2026-07-28` removed the server's ability to do that. Clients default to `mode="auto"`, which negotiates the newest version both sides support, so every example on this page passes `mode="legacy"`. See [protocol negotiation](/clients/client#protocol-negotiation).
18+
</Note>
19+
1620
## Handler Template
1721

1822
```python
@@ -49,6 +53,7 @@ async def sampling_handler(
4953

5054
client = Client(
5155
"my_mcp_server.py",
56+
mode="legacy",
5257
sampling_handler=sampling_handler,
5358
)
5459
```
@@ -109,6 +114,7 @@ from fastmcp.client.sampling.handlers.openai import OpenAISamplingHandler
109114

110115
client = Client(
111116
"my_mcp_server.py",
117+
mode="legacy",
112118
sampling_handler=OpenAISamplingHandler(default_model="gpt-4o"),
113119
)
114120
```
@@ -120,6 +126,7 @@ from openai import AsyncOpenAI
120126

121127
client = Client(
122128
"my_mcp_server.py",
129+
mode="legacy",
123130
sampling_handler=OpenAISamplingHandler(
124131
default_model="llama-3.1-70b",
125132
client=AsyncOpenAI(base_url="http://localhost:8000/v1"),
@@ -141,6 +148,7 @@ from fastmcp.client.sampling.handlers.anthropic import AnthropicSamplingHandler
141148

142149
client = Client(
143150
"my_mcp_server.py",
151+
mode="legacy",
144152
sampling_handler=AnthropicSamplingHandler(default_model="claude-sonnet-4-5"),
145153
)
146154
```
@@ -159,6 +167,7 @@ from fastmcp.client.sampling.handlers.google_genai import GoogleGenaiSamplingHan
159167

160168
client = Client(
161169
"my_mcp_server.py",
170+
mode="legacy",
162171
sampling_handler=GoogleGenaiSamplingHandler(default_model="gemini-2.0-flash"),
163172
)
164173
```
@@ -176,6 +185,7 @@ from fastmcp.types import SamplingCapability
176185

177186
client = Client(
178187
"my_mcp_server.py",
188+
mode="legacy",
179189
sampling_handler=basic_handler,
180190
sampling_capabilities=SamplingCapability(), # No tool support
181191
)

docs/clients/tasks.mdx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,14 +14,18 @@ Use this when you need to run long operations asynchronously while doing other w
1414

1515
The MCP task protocol lets you request operations to run in the background. The call returns a Task object immediately, letting you track progress, cancel operations, or await results.
1616

17+
<Note>
18+
**Background tasks require the older MCP protocol.** FastMCP submits a task over the session that the `initialize` handshake opens, and protocol version `2026-07-28` has no equivalent. Clients default to `mode="auto"`, which negotiates the newest version both sides support, so the examples on this page pass `mode="legacy"`. See [protocol negotiation](/clients/client#protocol-negotiation).
19+
</Note>
20+
1721
## Requesting Background Execution
1822

1923
Pass `task=True` to run an operation as a background task:
2024

2125
```python
2226
from fastmcp import Client
2327

24-
async with Client(server) as client:
28+
async with Client(server, mode="legacy") as client:
2529
# Start a background task
2630
task = await client.call_tool("slow_computation", {"duration": 10}, task=True)
2731

@@ -154,7 +158,7 @@ import asyncio
154158
from fastmcp import Client
155159

156160
async def main():
157-
async with Client(server) as client:
161+
async with Client(server, mode="legacy") as client:
158162
# Start background task
159163
task = await client.call_tool(
160164
"slow_computation",

docs/clients/transports.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ client = Client(transport)
8686

8787
async def efficient_multiple_operations():
8888
async with client:
89-
await client.ping()
89+
await client.list_tools()
9090

9191
async with client: # Reuses the same subprocess
9292
await client.call_tool("process_data", {"file": "data.csv"})

0 commit comments

Comments
 (0)