From d2ac7ed3d2f91d2c33c54319a2c5b1836bdc816d Mon Sep 17 00:00:00 2001
From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com>
Date: Sun, 19 Jul 2026 21:22:49 -0400
Subject: [PATCH] Default fastmcp.Client to mode="auto"; surface
extensions=/result_claims=
Client negotiates the newest mutual protocol era by default (probe
server/discover, fall back to the initialize handshake). ProxyClient and the
inspect utility explicitly pin the handshake era so proxy forwarding and
server_info reads are unchanged. SSE and multi-server config transports are
legacy-only. extensions= and result_claims= (SEP-2133) are thin passthroughs
to the SDK session.
---
docs/clients/client.mdx | 39 ++-
docs/development/v4-notes/change-register.mdx | 23 +-
docs/development/v4-notes/protocol-2026.mdx | 2 +-
fastmcp_slim/fastmcp/client/client.py | 234 +++++++++++--
fastmcp_slim/fastmcp/client/mixins/tools.py | 23 +-
.../fastmcp/client/transports/base.py | 13 +-
.../fastmcp/client/transports/config.py | 15 +
fastmcp_slim/fastmcp/client/transports/sse.py | 4 +
fastmcp_slim/fastmcp/mcp_config.py | 4 +-
.../fastmcp/server/providers/proxy.py | 5 +-
fastmcp_slim/fastmcp/utilities/inspect.py | 9 +-
tests/client/auth/test_oauth_client.py | 20 +-
tests/client/auth/test_oauth_static_client.py | 1 +
tests/client/client/test_client.py | 19 +-
tests/client/client/test_error_handling.py | 31 +-
tests/client/client/test_initialize.py | 20 +-
tests/client/client/test_mode_negotiation.py | 102 +++++-
.../client/tasks/test_client_prompt_tasks.py | 10 +-
.../tasks/test_client_resource_tasks.py | 12 +-
.../tasks/test_client_task_notifications.py | 20 +-
.../client/tasks/test_client_task_protocol.py | 6 +-
tests/client/tasks/test_client_tool_tasks.py | 18 +-
.../tasks/test_task_context_validation.py | 24 +-
.../client/tasks/test_task_result_caching.py | 26 +-
.../telemetry/test_client_task_tracing.py | 4 +-
tests/client/telemetry/test_client_tracing.py | 4 +-
tests/client/test_client_extensions.py | 331 ++++++++++++++++++
tests/client/test_elicitation.py | 106 ++++--
tests/client/test_elicitation_enums.py | 20 +-
tests/client/test_logs.py | 12 +-
tests/client/test_openapi.py | 6 +-
tests/client/test_roots.py | 3 +-
tests/client/test_sampling.py | 17 +-
tests/client/test_streamable_http.py | 17 +-
.../transports/test_memory_transport.py | 2 +-
tests/deprecated/test_elicitation.py | 4 +-
tests/server/auth/test_authorization.py | 21 +-
tests/server/middleware/test_caching.py | 4 +-
.../test_initialization_middleware.py | 22 +-
.../middleware/test_message_visibility.py | 12 +-
tests/server/middleware/test_middleware.py | 4 +-
.../middleware/test_middleware_nested.py | 2 +-
tests/server/middleware/test_ping.py | 4 +-
.../server/middleware/test_tool_injection.py | 5 +-
.../providers/proxy/test_proxy_client.py | 94 ++++-
.../providers/proxy/test_proxy_server.py | 151 ++++----
.../proxy/test_stateful_proxy_client.py | 16 +-
.../tasks/test_concurrent_dependencies.py | 16 +-
.../tasks/test_context_background_task.py | 34 +-
.../tasks/test_custom_subclass_tasks.py | 10 +-
tests/server/tasks/test_notifications.py | 5 +-
.../server/tasks/test_progress_dependency.py | 10 +-
.../test_resource_task_meta_parameter.py | 16 +-
.../tasks/test_server_tasks_parameter.py | 26 +-
tests/server/tasks/test_snapshot_restore.py | 6 +-
tests/server/tasks/test_task_capabilities.py | 4 +-
tests/server/tasks/test_task_config.py | 30 +-
tests/server/tasks/test_task_dependencies.py | 18 +-
.../tasks/test_task_elicitation_relay.py | 16 +-
.../server/tasks/test_task_meta_parameter.py | 20 +-
tests/server/tasks/test_task_metadata.py | 6 +-
tests/server/tasks/test_task_methods.py | 20 +-
tests/server/tasks/test_task_mount.py | 84 ++---
tests/server/tasks/test_task_prompts.py | 8 +-
tests/server/tasks/test_task_protocol.py | 6 +-
tests/server/tasks/test_task_proxy.py | 20 +-
tests/server/tasks/test_task_resources.py | 10 +-
tests/server/tasks/test_task_return_types.py | 32 +-
tests/server/tasks/test_task_security.py | 16 +-
.../tasks/test_task_status_notifications.py | 14 +-
tests/server/tasks/test_task_tools.py | 16 +-
tests/server/tasks/test_task_ttl.py | 6 +-
.../server/telemetry/test_sampling_tracing.py | 28 +-
tests/server/telemetry/test_server_tracing.py | 12 +-
tests/server/test_icons.py | 12 +-
tests/server/test_mrtr_guards.py | 5 +-
tests/server/test_protocol_eras.py | 2 +-
tests/server/test_session_visibility.py | 52 +--
tests/server/test_tool_annotations.py | 2 +-
tests/server/transforms/test_search.py | 2 +-
tests/test_apps.py | 4 +-
tests/test_compat.py | 2 +-
tests/test_mcp_config.py | 41 +++
.../tool_transform/test_tool_transform.py | 2 +-
84 files changed, 1553 insertions(+), 601 deletions(-)
create mode 100644 tests/client/test_client_extensions.py
diff --git a/docs/clients/client.mdx b/docs/clients/client.mdx
index 3d93b13ec..0f008355a 100644
--- a/docs/clients/client.mdx
+++ b/docs/clients/client.mdx
@@ -171,19 +171,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:
@@ -201,7 +201,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"`. 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.
## Response caching
@@ -260,6 +262,31 @@ client = Client("https://example.com/mcp", mode="auto", cache=config)
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.
+## 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. 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.
+
+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 9c051d63c..02d56f938 100644
--- a/docs/development/v4-notes/change-register.mdx
+++ b/docs/development/v4-notes/change-register.mdx
@@ -188,7 +188,28 @@ There is deliberately no compatibility alias for the old spelling.
## 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. 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`. `MCPConfigTransport` reports `legacy_only` as a property: a multi-server config is legacy-only (each backend is mounted behind a legacy-era proxy), while a single-server config mirrors its one backend transport's era so a modern Streamable HTTP backend stays modern-capable. Two internal library clients that are inherently handshake-based are pinned to legacy so the flip does not break them: the `ProxyClient` backend (which forwards the initialize handshake and server-initiated features) defaults to `mode="legacy"`, and the `inspect` utility (which reads the full `server_info` only the handshake carries) connects legacy.
+
+```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,config}.py` (`legacy_only`), `fastmcp_slim/fastmcp/server/providers/proxy.py` (`ProxyClient` legacy default), `fastmcp_slim/fastmcp/mcp_config.py` and `fastmcp_slim/fastmcp/utilities/inspect.py` (legacy inner clients), `tests/client/client/test_mode_negotiation.py` (default, clean discover-rejection fallback, legacy-only transport), `tests/test_mcp_config.py` (single- vs multi-server `legacy_only`), `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. 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).
### Transports yield 2-tuples — Absorbed
diff --git a/docs/development/v4-notes/protocol-2026.mdx b/docs/development/v4-notes/protocol-2026.mdx
index d6f0ce0c9..8dda7eb6b 100644
--- a/docs/development/v4-notes/protocol-2026.mdx
+++ b/docs/development/v4-notes/protocol-2026.mdx
@@ -39,7 +39,7 @@ The complete picture of what a FastMCP v4 server and client provide on the `2026
| **Cache hints (SEP-2549)** | Server-level authoring (`FastMCP(cache_ttl=..., cache_scope=...)`) stamps every cacheable result; the FastMCP client honors hints with an opt-in response cache. |
| **Distributed response caching** | `KeyValueResponseCacheStore` backs the client cache with any key-value store (Redis, memory, filetree), so a fleet of clients or proxy replicas shares cache fills across processes. |
| **Resource path security** | Templated resource parameters are screened for traversal, absolute paths, and null bytes before handlers run — on by default, including provider-sourced and mounted templates. |
-| **Client protocol negotiation** | `Client(mode="auto")` probes `server/discover` and falls back to the classic handshake; the client answers multi-round-trip `input_required` requests through its existing handlers. |
+| **Client protocol negotiation** | `Client(mode="auto")` — the default as of v4 — probes `server/discover` and falls back to the classic handshake; the client answers multi-round-trip `input_required` requests through its existing handlers. Pin `mode="legacy"` to force the handshake. |
| **Elicitation on the modern protocol (SEP-2322)** | Tools request user input via multi-round trips: a tool returns an `InputRequiredResult` and re-runs per round, reading the client's answers off `ctx.input_responses` / `ctx.request_state` (the [guard pattern](/servers/elicitation#elicitation-on-the-modern-protocol)). Each round is a complete request→response cycle; the framework seals `request_state` on the wire and unseals it before the tool runs, and a shared-key `request_state_security` policy carries state across replicas. On handshake-era connections returning this result produces a clear era error. |
| **Spec-standard errors (SEP-2164)** | Missing-resource reads return `-32602`; push-feature calls on modern connections fail with clear era-specific errors rather than generic method-not-found. |
| **Background tasks** | `@mcp.tool(task=True)` runs on a Redis-backed distributed runtime (Docket) with cross-replica notifications — execution infrastructure that is FastMCP's own, independent of the protocol-era task surface. |
diff --git a/fastmcp_slim/fastmcp/client/client.py b/fastmcp_slim/fastmcp/client/client.py
index ba657560b..e3e019536 100644
--- a/fastmcp_slim/fastmcp/client/client.py
+++ b/fastmcp_slim/fastmcp/client/client.py
@@ -8,7 +8,7 @@ import secrets
import ssl
import uuid
import weakref
-from collections.abc import Callable, Coroutine
+from collections.abc import Callable, Coroutine, Mapping, Sequence
from contextlib import AsyncExitStack, asynccontextmanager, suppress
from dataclasses import dataclass, field
from pathlib import Path
@@ -31,7 +31,12 @@ from mcp.client.caching import (
ClientResponseCache,
InMemoryResponseCacheStore,
)
-from mcp.client.extension import NotificationBinding
+from mcp.client.extension import (
+ ClaimContext,
+ ClientExtension,
+ NotificationBinding,
+ ResultClaim,
+)
from mcp.client.session import ClientRequestContext, MessageHandlerFnT
from mcp_types import (
GetTaskResult,
@@ -121,11 +126,11 @@ CacheableT = TypeVar("CacheableT", bound=mcp_types.CacheableResult)
ConnectMode = Literal["legacy", "auto"] | str
"""How the client negotiates the protocol era at connect time.
-- ``"legacy"`` (the current default): the classic initialize handshake, byte-identical
- to pre-v4 behavior for handshake-era servers.
-- ``"auto"``: probe ``server/discover`` at the newest modern version and adopt it, falling
- back to the initialize handshake for any server that is not positive evidence of a modern
- peer (a denylist fallback — see the SDK's ``negotiate_auto``).
+- ``"auto"`` (the default): probe ``server/discover`` at the newest modern version and
+ adopt it, falling back to the initialize handshake for any server that is not positive
+ evidence of a modern peer (a denylist fallback — see the SDK's ``negotiate_auto``).
+- ``"legacy"``: the classic initialize handshake, byte-identical to pre-v4 behavior for
+ handshake-era servers. Opt into this to force the old handshake.
- a modern protocol-version string (e.g. ``"2026-07-28"``): adopt that version directly
without probing, synthesizing a minimal ``DiscoverResult`` when none is supplied.
@@ -149,6 +154,92 @@ def _synthesize_discover(protocol_version: str) -> mcp_types.DiscoverResult:
)
+@dataclass
+class _FoldedExtensions:
+ """`Client(extensions=...)` folded into the shapes `ClientSession` consumes.
+
+ `ad` maps each extension identifier to its advertised settings (the SEP-2133
+ capability ad), `claims` maps each identifier to its `ResultClaim`s, `bindings`
+ is the flat list of `NotificationBinding`s the extensions observe, and `by_model`
+ indexes every claim by its result model so a claimed `tools/call` result can be
+ routed back to the owning resolver.
+ """
+
+ ad: dict[str, dict[str, Any]]
+ claims: dict[str, tuple[ResultClaim[Any], ...]]
+ bindings: list[NotificationBinding[Any]]
+ by_model: dict[type[mcp_types.Result], ResultClaim[Any]]
+
+
+def _fold_extensions(
+ extensions: Sequence[ClientExtension] | None,
+) -> _FoldedExtensions:
+ """Decompose `ClientExtension` instances into `ClientSession` kwargs.
+
+ Mirrors the SDK Client's own folding, using only the public `ClientExtension`
+ surface (`settings()`, `claims()`, `notifications()`). Duplicate identifiers,
+ result-type tags, or notification methods across extensions raise here rather
+ than at session construction, naming both owners. `by_model` is the model→claim
+ index the resolution path uses to finish a claimed result.
+ """
+ folded = _FoldedExtensions(ad={}, claims={}, bindings=[], by_model={})
+ if not extensions:
+ return folded
+ if isinstance(extensions, Mapping):
+ raise TypeError(
+ "extensions= takes a sequence of ClientExtension instances; use "
+ "mcp.client.advertise(identifier, settings) for an advertise-only entry"
+ )
+ claim_owners: dict[str, str] = {}
+ binding_owners: dict[str, str] = {}
+ for extension in extensions:
+ identifier = getattr(extension, "identifier", None)
+ if identifier is None:
+ raise ValueError(
+ f"{type(extension).__name__} has no `identifier`; a ClientExtension "
+ "must set the `identifier` class attribute (or assign one in "
+ "`__init__`) before it can be used"
+ )
+ if identifier in folded.ad:
+ raise ValueError(
+ f"extension identifier {identifier!r} is passed more than once"
+ )
+ folded.ad[identifier] = extension.settings()
+ extension_claims = tuple(extension.claims())
+ for claim in extension_claims:
+ tag = claim.result_type
+ if tag in claim_owners:
+ owner = claim_owners[tag]
+ both = (
+ f"extension {identifier!r} claims"
+ if owner == identifier
+ else f"extensions {owner!r} and {identifier!r} both claim"
+ )
+ raise ValueError(
+ f"{both} resultType {tag!r}; a wire tag can have only one resolver"
+ )
+ claim_owners[tag] = identifier
+ # Each model pins its result_type Literal to one tag, so this cannot collide.
+ folded.by_model[claim.model] = claim
+ if extension_claims:
+ folded.claims[identifier] = extension_claims
+ for binding in extension.notifications():
+ if binding.method in binding_owners:
+ owner = binding_owners[binding.method]
+ both = (
+ f"extension {identifier!r} binds"
+ if owner == identifier
+ else f"extensions {owner!r} and {identifier!r} both bind"
+ )
+ raise ValueError(
+ f"{both} notification method {binding.method!r}; a method can "
+ "have only one observer"
+ )
+ binding_owners[binding.method] = identifier
+ folded.bindings.append(binding)
+ return folded
+
+
def _evicting_message_handler(
cache: ClientResponseCache, user_handler: MessageHandlerFnT | None
) -> MessageHandlerFnT:
@@ -259,12 +350,13 @@ class Client(
timeout: Optional timeout for requests (seconds or timedelta)
init_timeout: Optional timeout for initial connection (seconds or timedelta).
Set to 0 to disable. If None, uses the value in the FastMCP global settings.
- mode: Protocol-era negotiation at connect time. `"legacy"` (the default) runs
- the initialize handshake, byte-identical to pre-v4 behavior. `"auto"` probes
+ mode: Protocol-era negotiation at connect time. `"auto"` (the default) probes
`server/discover` and negotiates the modern era, denylist-falling-back to the
- handshake for legacy servers. A modern version string (e.g. `"2026-07-28"`)
- adopts that version directly. `mode="auto"` as a future default is a
- release-time decision; the conservative `"legacy"` is the default for now.
+ initialize handshake for any server that is not positive evidence of a modern
+ peer — safe against a mixed fleet of legacy and modern servers. `"legacy"`
+ forces the initialize handshake, byte-identical to pre-v4 behavior; opt into it
+ to pin the old handshake. A modern version string (e.g. `"2026-07-28"`) adopts
+ that version directly without a probe.
prior_discover: A previously obtained `DiscoverResult` to adopt when `mode` is a
version pin, reused instead of synthesizing a minimal one. Ignored otherwise.
input_required_max_rounds: Cap on `InputRequiredResult` (SEP-2322) retry rounds
@@ -276,6 +368,19 @@ class Client(
modern-only, so a cache is inert on legacy connections. A custom `CacheConfig`
store requires `target_id`, since FastMCP transports expose no server URL to
derive a shared-store identity from.
+ extensions: Opt-in client extensions (SEP-2133), a sequence of
+ `mcp.client.extension.ClientExtension` instances. Each contributes its
+ capability advertisement, its result claims, and its notification bindings,
+ all of which are threaded into the underlying session. User-supplied
+ notification bindings compose with FastMCP's internal task-status binding
+ rather than replacing it. A claimed `call_tool` result is resolved
+ transparently through the owning extension's resolver. For an advertise-only
+ entry, use `mcp.client.advertise(identifier, settings)`.
+ result_claims: Additional `ResultClaim`s (SEP-2133) keyed by the identifier of
+ an extension already advertised through `extensions`, merged with that
+ extension's own claims. Rarely needed directly; prefer declaring claims on
+ the extension itself. Claimed shapes are modern-only and inert on a legacy
+ connection.
Examples:
```python
@@ -365,10 +470,12 @@ class Client(
client_info: mcp_types.Implementation | None = None,
auth: httpx2.Auth | Literal["oauth"] | str | None = None,
verify: ssl.SSLContext | bool | str | None = None,
- mode: ConnectMode = "legacy",
+ mode: ConnectMode = "auto",
prior_discover: mcp_types.DiscoverResult | None = None,
input_required_max_rounds: int = DEFAULT_INPUT_REQUIRED_MAX_ROUNDS,
cache: CacheConfig | bool | None = None,
+ extensions: Sequence[ClientExtension] | None = None,
+ result_claims: Mapping[str, Sequence[ResultClaim[Any]]] | None = None,
) -> None:
self.name = name or self.generate_name()
@@ -453,6 +560,14 @@ class Client(
self._response_cache, effective_message_handler
)
+ # Opt-in client extensions (SEP-2133) and their result claims. Retained so
+ # `new()` can rebuild an independent set of session kwargs per clone.
+ self._extensions_arg = extensions
+ self._result_claims_arg = result_claims
+ # Model→claim index the resolution path uses; (re)built by
+ # `_build_extension_kwargs`.
+ self._claim_by_model: dict[type[mcp_types.Result], ResultClaim[Any]] = {}
+
self._session_kwargs: SessionKwargs = {
"sampling_callback": None,
"list_roots_callback": None,
@@ -460,10 +575,7 @@ class Client(
"message_handler": effective_message_handler,
"read_timeout_seconds": read_timeout_seconds,
"client_info": client_info,
- # SDK v2 does not carry `notifications/tasks/status` in any protocol
- # version's core notification tables, so it is never tee'd to the
- # message_handler; a binding routes it to Task objects instead.
- "notification_bindings": [self._task_status_binding()],
+ **self._build_extension_kwargs(),
}
if roots is not None:
@@ -687,10 +799,10 @@ class Client(
)
else:
new_client._session_kwargs["message_handler"] = base_handler
- # Rebind the task-status notification binding so it routes to the clone.
- new_client._session_kwargs["notification_bindings"] = [
- new_client._task_status_binding()
- ]
+ # Rebuild the extension-contributed kwargs (capability ad, result claims,
+ # notification bindings) so the clone's task-status binding routes to the
+ # clone while user extensions still compose with it.
+ new_client._session_kwargs.update(new_client._build_extension_kwargs())
new_client.name += f":{secrets.token_hex(2)}"
@@ -745,13 +857,20 @@ class Client(
else:
timeout = normalize_timeout_to_seconds(timeout)
+ # A legacy-only transport (SSE, a multi-server proxy config) cannot serve
+ # the modern era; treat "auto" as "legacy" there rather than probing
+ # server/discover, which some such servers answer but then cannot serve.
+ effective_mode = self.mode
+ if effective_mode == "auto" and self.transport.legacy_only:
+ effective_mode = "legacy"
+
try:
with anyio.fail_after(timeout):
- if self.mode == "legacy":
+ if effective_mode == "legacy":
self._session_state.initialize_result = (
await self.session.initialize()
)
- elif self.mode == "auto":
+ elif effective_mode == "auto":
await negotiate_auto(self.session)
# auto may have fallen back to the legacy handshake; surface its
# InitializeResult through the existing public property when so.
@@ -782,7 +901,7 @@ class Client(
With `mode="auto"` or a pinned modern version, connect-time negotiation may adopt
the modern `server/discover` era, which has no `InitializeResult`; in that case
this method raises. Read `protocol_version` / `server_capabilities` instead, or use
- `mode="legacy"` (the default) when you need the handshake result.
+ `mode="legacy"` when you need the handshake result.
Args:
timeout: Optional timeout for the initialization request (seconds or timedelta).
@@ -1170,6 +1289,73 @@ class Client(
status = GetTaskResult.model_validate(params.model_dump())
task._handle_status_notification(status)
+ def _build_extension_kwargs(self) -> SessionKwargs:
+ """Session kwargs contributed by `extensions=` / `result_claims=`.
+
+ Folds the user's `ClientExtension` instances into the capability ad, result
+ claims, and notification bindings the SDK `ClientSession` consumes, then
+ merges in any explicitly-passed `result_claims`. The internal task-status
+ binding is always prepended to the folded bindings so user extensions
+ *compose* with it rather than clobbering it; a user extension that binds the
+ same `notifications/tasks/status` method surfaces a duplicate-method error
+ from the SDK rather than silently replacing FastMCP's routing.
+
+ Also rebuilds `self._claim_by_model`, the model→claim index the resolution
+ path uses to finish a claimed `tools/call` result, covering both the folded
+ extension claims and the explicit `result_claims` extras.
+ """
+ folded = _fold_extensions(self._extensions_arg)
+
+ claims: dict[str, tuple[ResultClaim[Any], ...]] = dict(folded.claims)
+ 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, ())
+ claims[identifier] = (*existing, *extra)
+ for claim in extra:
+ by_model[claim.model] = claim
+ self._claim_by_model = by_model
+
+ kwargs: SessionKwargs = {
+ # The internal task binding must lead so user bindings extend it.
+ "notification_bindings": [
+ self._task_status_binding(),
+ *folded.bindings,
+ ],
+ }
+ if folded.ad:
+ kwargs["extensions"] = folded.ad
+ if claims:
+ kwargs["result_claims"] = claims
+ return kwargs
+
+ async def _resolve_claimed_result(
+ self,
+ name: str,
+ result: mcp_types.Result,
+ read_timeout_seconds: float | None,
+ ) -> mcp_types.CallToolResult:
+ """Finish a claimed `tools/call` result through its owning extension.
+
+ A modern server may answer `tools/call` with a claimed extension shape
+ (SEP-2133). The session parses it into the claim's model; this hands that
+ model to the owning claim's resolver — which may send follow-up requests
+ through the session — and returns the ordinary `CallToolResult` it
+ produces. Mirrors the SDK Client's resolution path, including the
+ output-schema revalidation the direct path performs.
+ """
+ claim = self._claim_by_model[type(result)]
+ final = await claim.resolve(
+ result,
+ ClaimContext(
+ session=self.session,
+ tool_name=name,
+ read_timeout_seconds=read_timeout_seconds,
+ ),
+ )
+ if not final.is_error:
+ await self.session.validate_tool_result(name, final)
+ return final
+
def _task_status_binding(self) -> NotificationBinding[TaskStatusNotificationParams]:
"""Build a binding routing `notifications/tasks/status` to Task objects.
diff --git a/fastmcp_slim/fastmcp/client/mixins/tools.py b/fastmcp_slim/fastmcp/client/mixins/tools.py
index 6e0a0de11..418976f69 100644
--- a/fastmcp_slim/fastmcp/client/mixins/tools.py
+++ b/fastmcp_slim/fastmcp/client/mixins/tools.py
@@ -195,10 +195,20 @@ class ClientToolsMixin:
read_timeout_seconds = normalize_timeout_to_seconds(timeout)
progress_callback = progress_handler or self._progress_handler
+ # Only opt into claimed results (SEP-2133) when this client registered
+ # an extension that claims one; otherwise keep the SDK's default, which
+ # surfaces an unexpected claimed result as an error rather than parsing
+ # a shape we have no resolver for.
+ has_claims = bool(self._claim_by_model)
+
async def _retry(
input_responses: mcp_types.InputResponses | None,
request_state: str | None,
- ) -> mcp_types.CallToolResult | mcp_types.InputRequiredResult:
+ ) -> (
+ mcp_types.CallToolResult
+ | mcp_types.InputRequiredResult
+ | mcp_types.Result
+ ):
return await self.session.call_tool(
name=name,
arguments=arguments,
@@ -208,12 +218,21 @@ class ClientToolsMixin:
input_responses=input_responses,
request_state=request_state,
allow_input_required=True,
+ allow_claimed=has_claims,
)
first = await self._await_with_session_monitoring(_retry(None, None))
- result = await self._await_with_session_monitoring(
+ driven = await self._await_with_session_monitoring(
self._drive_input_required(first, _retry)
)
+ if isinstance(driven, mcp_types.CallToolResult):
+ result = driven
+ else:
+ # A claimed extension result (SEP-2133): resolve it through the
+ # owning extension's resolver into an ordinary CallToolResult.
+ result = await self._resolve_claimed_result(
+ name, driven, read_timeout_seconds
+ )
# Reflect tool-level errors on the span so callers see ERROR
# status even though the MCP protocol call itself succeeded.
diff --git a/fastmcp_slim/fastmcp/client/transports/base.py b/fastmcp_slim/fastmcp/client/transports/base.py
index bec8bd0ae..b6f4ced66 100644
--- a/fastmcp_slim/fastmcp/client/transports/base.py
+++ b/fastmcp_slim/fastmcp/client/transports/base.py
@@ -1,13 +1,13 @@
import abc
import contextlib
-from collections.abc import AsyncIterator, Sequence
+from collections.abc import AsyncIterator, Mapping, Sequence
from dataclasses import dataclass
from typing import Any, Literal, TypeVar
import httpx2
import mcp_types
from mcp import ClientSession
-from mcp.client.extension import NotificationBinding
+from mcp.client.extension import NotificationBinding, ResultClaim
from mcp.client.session import (
ElicitationFnT,
ListRootsFnT,
@@ -33,6 +33,8 @@ class ClientSessionKwargs(TypedDict, total=False):
message_handler: MessageHandlerFnT | None
client_info: mcp_types.Implementation | None
notification_bindings: Sequence[NotificationBinding[Any]] | None
+ extensions: dict[str, dict[str, Any]] | None
+ result_claims: Mapping[str, Sequence[ResultClaim[Any]]] | None
@dataclass(frozen=True)
@@ -70,6 +72,13 @@ class ClientTransport(abc.ABC):
"""
+ #: Whether this transport can only carry the legacy (handshake) protocol era.
+ #: The modern `2026-07-28` era is sessionless and served over Streamable HTTP;
+ #: the SSE transport predates it and cannot serve it. When True, a client with
+ #: `mode="auto"` negotiates the legacy handshake directly rather than probing
+ #: `server/discover` (which some servers answer over SSE but then cannot serve).
+ legacy_only: bool = False
+
@abc.abstractmethod
@contextlib.asynccontextmanager
async def connect_session(
diff --git a/fastmcp_slim/fastmcp/client/transports/config.py b/fastmcp_slim/fastmcp/client/transports/config.py
index 6b271ab53..ede949402 100644
--- a/fastmcp_slim/fastmcp/client/transports/config.py
+++ b/fastmcp_slim/fastmcp/client/transports/config.py
@@ -91,6 +91,21 @@ class MCPConfigTransport(ClientTransport):
self.transport = next(iter(self.config.mcpServers.values())).to_transport()
self._transports.append(self.transport)
+ @property
+ def legacy_only(self) -> bool:
+ """Whether this config can only carry the legacy protocol era.
+
+ A single-server config delegates directly to the underlying transport
+ (no proxy), so it inherits that transport's era capability — a modern
+ Streamable HTTP backend must stay modern-capable under `mode="auto"`.
+ A multi-server config mounts each backend behind a legacy-era
+ `ProxyClient` on a composite server, so the composite it exposes is
+ legacy-era and `mode="auto"` should negotiate the handshake.
+ """
+ if len(self.config.mcpServers) == 1:
+ return self.transport.legacy_only
+ return True
+
@contextlib.asynccontextmanager
async def connect_session(
self,
diff --git a/fastmcp_slim/fastmcp/client/transports/sse.py b/fastmcp_slim/fastmcp/client/transports/sse.py
index a86316036..84dbdadda 100644
--- a/fastmcp_slim/fastmcp/client/transports/sse.py
+++ b/fastmcp_slim/fastmcp/client/transports/sse.py
@@ -29,6 +29,10 @@ from fastmcp.utilities.timeout import normalize_timeout_to_timedelta
class SSETransport(ClientTransport):
"""Transport implementation that connects to an MCP server via Server-Sent Events."""
+ # SSE predates the sessionless modern era and cannot serve it; a client with
+ # `mode="auto"` negotiates the legacy handshake directly over SSE.
+ legacy_only = True
+
def __init__(
self,
url: str | AnyUrl,
diff --git a/fastmcp_slim/fastmcp/mcp_config.py b/fastmcp_slim/fastmcp/mcp_config.py
index 01416bd2d..6882c06e7 100644
--- a/fastmcp_slim/fastmcp/mcp_config.py
+++ b/fastmcp_slim/fastmcp/mcp_config.py
@@ -137,7 +137,9 @@ class _TransformingMCPServerMixin(BaseModel):
) from exc
transport = cast("ClientTransport", super().to_transport()) # ty: ignore[unresolved-attribute]
- client = Client(transport=transport, name=client_name)
+ # The proxy that wraps this client forwards the initialize handshake and
+ # server-initiated features, which require the legacy era.
+ client = Client(transport=transport, name=client_name, mode="legacy")
wrapped_mcp_server = create_proxy(client, name=server_name)
if self.include_tags is not None:
diff --git a/fastmcp_slim/fastmcp/server/providers/proxy.py b/fastmcp_slim/fastmcp/server/providers/proxy.py
index f13f365e2..8e4f4ed12 100644
--- a/fastmcp_slim/fastmcp/server/providers/proxy.py
+++ b/fastmcp_slim/fastmcp/server/providers/proxy.py
@@ -1220,7 +1220,10 @@ class ProxyClient(Client[ClientTransportT]):
# via the handlers installed below) that proxies rely on. To round-trip
# an upstream guard tool's InputRequiredResult (SEP-2322) instead, opt
# into the modern era explicitly with `create_proxy(target, mode="auto")`
- # — the two are mutually exclusive per session.
+ # — the two are mutually exclusive per session. This pin is explicit
+ # rather than inherited, so flipping `Client`'s own default to `"auto"`
+ # never changes proxy behavior.
+ kwargs.setdefault("mode", "legacy")
# Install context-restoring handler wrappers BEFORE super().__init__
# registers them with the Client's session kwargs.
self._proxy_rc_ref = [None]
diff --git a/fastmcp_slim/fastmcp/utilities/inspect.py b/fastmcp_slim/fastmcp/utilities/inspect.py
index b9e3d0827..1ce403643 100644
--- a/fastmcp_slim/fastmcp/utilities/inspect.py
+++ b/fastmcp_slim/fastmcp/utilities/inspect.py
@@ -257,8 +257,9 @@ async def inspect_fastmcp_v1(mcp: SDKServer) -> FastMCPInfo:
Returns:
FastMCPInfo dataclass containing the extracted information
"""
- # Use a client to interact with the SDK's high-level MCPServer
- async with Client(mcp) as client:
+ # Inspection reads the full server_info (icons, website_url) that only the
+ # legacy initialize handshake carries, so pin the handshake era.
+ async with Client(mcp, mode="legacy") as client:
# Get components via client calls (these return MCP objects)
mcp_tools = await client.list_tools()
mcp_prompts = await client.list_prompts()
@@ -467,7 +468,9 @@ async def format_mcp_info(mcp: FastMCP[Any] | SDKServer) -> bytes:
Uses Client to get the standard MCP protocol format with camelCase fields.
Includes version metadata at the top level.
"""
- async with Client(mcp) as client:
+ # Inspection reads the full server_info that only the legacy initialize
+ # handshake carries, so pin the handshake era.
+ async with Client(mcp, mode="legacy") as client:
# Get all the MCP protocol objects
tools_result = await client.list_tools_mcp()
prompts_result = await client.list_prompts_mcp()
diff --git a/tests/client/auth/test_oauth_client.py b/tests/client/auth/test_oauth_client.py
index 89427fd24..1ded252f8 100644
--- a/tests/client/auth/test_oauth_client.py
+++ b/tests/client/auth/test_oauth_client.py
@@ -101,10 +101,19 @@ async def test_unauthorized(client_unauthorized: Client):
pass
-async def test_ping(client_with_headless_oauth: Client):
- """Test that we can ping the server."""
- async with client_with_headless_oauth:
- assert await client_with_headless_oauth.ping()
+async def test_ping(streamable_http_server: str):
+ """Test that we can ping the server.
+
+ Pinned to legacy: `ping` is a handshake-era request removed from the modern
+ (2026-07-28) protocol.
+ """
+ client = Client(
+ transport=StreamableHttpTransport(streamable_http_server),
+ auth=HeadlessOAuth(mcp_url=streamable_http_server, scopes=["read", "write"]),
+ mode="legacy",
+ )
+ async with client:
+ assert await client.ping()
async def test_list_tools(client_with_headless_oauth: Client):
@@ -168,9 +177,12 @@ async def test_expired_dynamic_registration_is_retried():
server = FastMCP("TestServer", auth=provider)
async with run_server_async(server, port=port, transport="http") as url:
+ # Pinned to legacy: `ping` is a handshake-era request removed from the
+ # modern (2026-07-28) protocol; the retry is exercised via the handshake.
client = Client(
transport=StreamableHttpTransport(url),
auth=HeadlessOAuth(mcp_url=url),
+ mode="legacy",
)
async with client:
assert await client.ping()
diff --git a/tests/client/auth/test_oauth_static_client.py b/tests/client/auth/test_oauth_static_client.py
index 1fbae5c8e..9682c6d0a 100644
--- a/tests/client/auth/test_oauth_static_client.py
+++ b/tests/client/auth/test_oauth_static_client.py
@@ -221,6 +221,7 @@ class TestStaticClientE2E:
async with Client(
transport=StreamableHttpTransport(url),
auth=oauth,
+ mode="legacy", # `ping` is a handshake-era request
) as client:
assert await client.ping()
tools = await client.list_tools()
diff --git a/tests/client/client/test_client.py b/tests/client/client/test_client.py
index 86355ce9e..2096e599f 100644
--- a/tests/client/client/test_client.py
+++ b/tests/client/client/test_client.py
@@ -273,7 +273,12 @@ async def test_client_serialization_error():
async def test_server_deserialization_error():
- """Test server error when JSON string cannot be converted to expected type."""
+ """Test server error when JSON string cannot be converted to expected type.
+
+ Pinned to legacy: the detailed `PromptError` message is surfaced to the
+ client only on the handshake era; the modern server runner reports the
+ raised conversion error as a generic "Internal server error".
+ """
server = FastMCP("TestServer")
@@ -282,7 +287,7 @@ async def test_server_deserialization_error():
"""Expects list of integers but will receive invalid JSON."""
return f"Got {len(numbers)} numbers"
- client = Client(transport=FastMCPTransport(server))
+ client = Client(transport=FastMCPTransport(server), mode="legacy")
async with client:
with pytest.raises(MCPError, match="Could not convert argument"):
@@ -341,7 +346,7 @@ async def test_read_resource_mcp(fastmcp_server):
async def test_client_connection(fastmcp_server):
"""Test that connect is idempotent."""
- client = Client(transport=FastMCPTransport(fastmcp_server))
+ client = Client(transport=FastMCPTransport(fastmcp_server), mode="legacy")
# Connect idempotently
async with client:
@@ -353,7 +358,7 @@ async def test_client_connection(fastmcp_server):
async def test_initialize_called_once(fastmcp_server):
"""Test that initialization is called once and sets initialize_result."""
- client = Client(transport=FastMCPTransport(fastmcp_server))
+ client = Client(transport=FastMCPTransport(fastmcp_server), mode="legacy")
async with client:
# Verify that initialization succeeded by checking initialize_result
assert client.initialize_result is not None
@@ -362,7 +367,7 @@ async def test_initialize_called_once(fastmcp_server):
async def test_initialize_result_connected(fastmcp_server):
"""Test that initialize_result returns the correct result when connected."""
- client = Client(transport=FastMCPTransport(fastmcp_server))
+ client = Client(transport=FastMCPTransport(fastmcp_server), mode="legacy")
# Initialize result should be None before connection
assert client.initialize_result is None
@@ -397,7 +402,7 @@ async def test_server_info_custom_version():
"""Test that custom version is properly set in serverInfo."""
# Test with custom version
server_with_version = FastMCP("CustomVersionServer", version="1.2.3")
- client = Client(transport=FastMCPTransport(server_with_version))
+ client = Client(transport=FastMCPTransport(server_with_version), mode="legacy")
async with client:
result = client.initialize_result
@@ -407,7 +412,7 @@ async def test_server_info_custom_version():
# Test without version (backward compatibility)
server_without_version = FastMCP("DefaultVersionServer")
- client = Client(transport=FastMCPTransport(server_without_version))
+ client = Client(transport=FastMCPTransport(server_without_version), mode="legacy")
async with client:
result = client.initialize_result
diff --git a/tests/client/client/test_error_handling.py b/tests/client/client/test_error_handling.py
index aa1ed61d1..4ebe9f836 100644
--- a/tests/client/client/test_error_handling.py
+++ b/tests/client/client/test_error_handling.py
@@ -1,4 +1,13 @@
-"""Client error handling tests."""
+"""Client error handling tests.
+
+Resource, resource-template, and prompt error *detail* surfacing is a
+handshake-era behavior: the legacy read/get path converts a `ResourceError` /
+`PromptError` into a client-visible message, while the modern (2026-07-28)
+server runner surfaces the raised exception as a generic "Internal server
+error". Tests asserting the detailed message are pinned to `mode="legacy"`;
+tool-error tests (which flow through an `isError` `CallToolResult`) are
+era-neutral and run on the default `auto`.
+"""
import logging
@@ -85,7 +94,7 @@ class TestErrorHandling:
async def exception_resource():
raise ValueError("This is an internal error (sensitive)")
- client = Client(transport=FastMCPTransport(mcp))
+ client = Client(transport=FastMCPTransport(mcp), mode="legacy")
async with client:
with pytest.raises(Exception) as excinfo:
@@ -101,7 +110,7 @@ class TestErrorHandling:
async def exception_resource():
raise ValueError("This is an internal error (sensitive)")
- client = Client(transport=FastMCPTransport(mcp))
+ client = Client(transport=FastMCPTransport(mcp), mode="legacy")
async with client:
with pytest.raises(Exception) as excinfo:
@@ -117,7 +126,7 @@ class TestErrorHandling:
async def error_resource():
raise ResourceError("This is a resource error (xyz)")
- client = Client(transport=FastMCPTransport(mcp))
+ client = Client(transport=FastMCPTransport(mcp), mode="legacy")
async with client:
with pytest.raises(Exception) as excinfo:
@@ -131,7 +140,7 @@ class TestErrorHandling:
async def exception_resource(id: str):
raise ValueError("This is an internal error (sensitive)")
- client = Client(transport=FastMCPTransport(mcp))
+ client = Client(transport=FastMCPTransport(mcp), mode="legacy")
async with client:
with pytest.raises(Exception) as excinfo:
@@ -147,7 +156,7 @@ class TestErrorHandling:
async def exception_resource(id: str):
raise ValueError("This is an internal error (sensitive)")
- client = Client(transport=FastMCPTransport(mcp))
+ client = Client(transport=FastMCPTransport(mcp), mode="legacy")
async with client:
with pytest.raises(Exception) as excinfo:
@@ -163,7 +172,7 @@ class TestErrorHandling:
async def error_resource(id: str):
raise ResourceError("This is a resource error (xyz)")
- client = Client(transport=FastMCPTransport(mcp))
+ client = Client(transport=FastMCPTransport(mcp), mode="legacy")
async with client:
with pytest.raises(Exception) as excinfo:
@@ -326,7 +335,7 @@ class TestLogLevel:
"Resource unavailable, try again later", log_level=logging.WARNING
)
- async with Client(transport=FastMCPTransport(mcp)) as client:
+ async with Client(transport=FastMCPTransport(mcp), mode="legacy") as client:
with caplog.at_level(logging.WARNING):
with pytest.raises(Exception) as exc_info:
await client.read_resource_mcp("test://custom")
@@ -349,7 +358,7 @@ class TestLogLevel:
def regular_resource():
raise ResourceError("Something went wrong")
- async with Client(transport=FastMCPTransport(mcp)) as client:
+ async with Client(transport=FastMCPTransport(mcp), mode="legacy") as client:
with caplog.at_level(logging.ERROR):
with pytest.raises(Exception) as exc_info:
await client.read_resource_mcp("test://regular")
@@ -371,7 +380,7 @@ class TestLogLevel:
"Insufficient context, provide more details", log_level=logging.WARNING
)
- async with Client(transport=FastMCPTransport(mcp)) as client:
+ async with Client(transport=FastMCPTransport(mcp), mode="legacy") as client:
with caplog.at_level(logging.WARNING):
with pytest.raises(Exception) as exc_info:
await client.get_prompt("custom_level_prompt")
@@ -394,7 +403,7 @@ class TestLogLevel:
def regular_prompt():
raise PromptError("Something went wrong")
- async with Client(transport=FastMCPTransport(mcp)) as client:
+ async with Client(transport=FastMCPTransport(mcp), mode="legacy") as client:
with caplog.at_level(logging.ERROR):
with pytest.raises(Exception) as exc_info:
await client.get_prompt("regular_prompt")
diff --git a/tests/client/client/test_initialize.py b/tests/client/client/test_initialize.py
index 7bbe3aeb8..7106806ae 100644
--- a/tests/client/client/test_initialize.py
+++ b/tests/client/client/test_initialize.py
@@ -9,7 +9,7 @@ class TestInitialize:
async def test_auto_initialize_default(self, fastmcp_server):
"""Test that auto_initialize=True is the default and works automatically."""
- client = Client(fastmcp_server)
+ client = Client(fastmcp_server, mode="legacy")
async with client:
# Should be automatically initialized
@@ -19,7 +19,7 @@ class TestInitialize:
async def test_auto_initialize_explicit_true(self, fastmcp_server):
"""Test explicit auto_initialize=True."""
- client = Client(fastmcp_server, auto_initialize=True)
+ client = Client(fastmcp_server, mode="legacy", auto_initialize=True)
async with client:
assert client.initialize_result is not None
@@ -27,7 +27,7 @@ class TestInitialize:
async def test_auto_initialize_false(self, fastmcp_server):
"""Test that auto_initialize=False prevents automatic initialization."""
- client = Client(fastmcp_server, auto_initialize=False)
+ client = Client(fastmcp_server, mode="legacy", auto_initialize=False)
async with client:
# Should not be automatically initialized
@@ -35,7 +35,7 @@ class TestInitialize:
async def test_manual_initialize(self, fastmcp_server):
"""Test manual initialization when auto_initialize=False."""
- client = Client(fastmcp_server, auto_initialize=False)
+ client = Client(fastmcp_server, mode="legacy", auto_initialize=False)
async with client:
# Manually initialize
@@ -47,7 +47,7 @@ class TestInitialize:
async def test_initialize_idempotent(self, fastmcp_server):
"""Test that calling initialize() multiple times returns cached result."""
- client = Client(fastmcp_server, auto_initialize=False)
+ client = Client(fastmcp_server, mode="legacy", auto_initialize=False)
async with client:
result1 = await client.initialize()
@@ -66,7 +66,7 @@ class TestInitialize:
def greet(name: str) -> str:
return f"Hello, {name}!"
- client = Client(server)
+ client = Client(server, mode="legacy")
async with client:
result = client.initialize_result
@@ -75,7 +75,7 @@ class TestInitialize:
async def test_initialize_timeout_custom(self, fastmcp_server):
"""Test custom timeout for initialize()."""
- client = Client(fastmcp_server, auto_initialize=False)
+ client = Client(fastmcp_server, mode="legacy", auto_initialize=False)
async with client:
# Should succeed with reasonable timeout
@@ -84,7 +84,7 @@ class TestInitialize:
async def test_initialize_property_after_auto_init(self, fastmcp_server):
"""Test accessing initialize_result property after auto-initialization."""
- client = Client(fastmcp_server, auto_initialize=True)
+ client = Client(fastmcp_server, mode="legacy", auto_initialize=True)
async with client:
# Access via property
@@ -98,14 +98,14 @@ class TestInitialize:
async def test_initialize_property_before_connect(self, fastmcp_server):
"""Test that initialize_result property is None before connection."""
- client = Client(fastmcp_server)
+ client = Client(fastmcp_server, mode="legacy")
# Not yet connected
assert client.initialize_result is None
async def test_manual_initialize_can_call_tools(self, fastmcp_server):
"""Test that manually initialized client can call tools."""
- client = Client(fastmcp_server, auto_initialize=False)
+ client = Client(fastmcp_server, mode="legacy", auto_initialize=False)
async with client:
await client.initialize()
diff --git a/tests/client/client/test_mode_negotiation.py b/tests/client/client/test_mode_negotiation.py
index fc0b14f62..13aaca7e3 100644
--- a/tests/client/client/test_mode_negotiation.py
+++ b/tests/client/client/test_mode_negotiation.py
@@ -4,25 +4,36 @@ FastMCP serves both protocol eras from one server object over the in-memory
stream loop (``serve_dual_era_loop``), so a single ``fastmcp_server`` fixture can
be driven legacy or modern by varying ``mode=`` alone:
-* ``mode="legacy"`` (the current default) runs the initialize handshake and
- reports the handshake-era version, byte-identically to pre-v4 behavior.
-* ``mode="auto"`` probes ``server/discover`` and negotiates the modern era.
+* ``mode="auto"`` (the default) probes ``server/discover`` and negotiates the
+ modern era, denylist-falling-back to the initialize handshake for any server
+ that is not positive evidence of a modern peer.
+* ``mode="legacy"`` runs the initialize handshake and reports the handshake-era
+ version, byte-identically to pre-v4 behavior.
* ``mode="2026-07-28"`` pins the modern version and adopts a synthesized
``DiscoverResult`` without a probe.
"""
from __future__ import annotations
+import contextlib
+from collections.abc import AsyncIterator
+from typing import Any
+
import pytest
+from mcp import ClientSession
+from mcp.shared.exceptions import MCPError
+from mcp_types import METHOD_NOT_FOUND
from mcp_types.version import LATEST_HANDSHAKE_VERSION, LATEST_MODERN_VERSION
+from typing_extensions import Unpack
from fastmcp import Client, FastMCP
+from fastmcp.client.transports import FastMCPTransport, SessionKwargs
class TestModeValidation:
- def test_default_mode_is_legacy(self, fastmcp_server):
- """The conservative default is 'legacy' (see the v4 phasing note)."""
- assert Client(fastmcp_server).mode == "legacy"
+ def test_default_mode_is_auto(self, fastmcp_server):
+ """The default is 'auto': probe server/discover, fall back to the handshake."""
+ assert Client(fastmcp_server).mode == "auto"
@pytest.mark.parametrize("mode", ["legacy", "auto", LATEST_MODERN_VERSION])
def test_valid_modes_accepted(self, fastmcp_server, mode):
@@ -47,12 +58,6 @@ class TestLegacyMode:
assert client.initialize_result.server_info.name == "TestServer"
assert client.server_capabilities is not None
- async def test_default_matches_legacy(self, fastmcp_server):
- """Omitting mode= is byte-identical to mode='legacy'."""
- async with Client(fastmcp_server) as client:
- assert client.protocol_version == LATEST_HANDSHAKE_VERSION
- assert client.initialize_result is not None
-
async def test_legacy_call_tool(self, fastmcp_server):
async with Client(fastmcp_server, mode="legacy") as client:
result = await client.call_tool("add", {"a": 2, "b": 3})
@@ -73,14 +78,18 @@ class TestAutoMode:
result = await client.call_tool("add", {"a": 4, "b": 5})
assert result.data == 9
- async def test_auto_falls_back_to_legacy_for_handshake_only_server(self):
- """A server that only speaks the handshake era makes auto denylist-fall-back to
- initialize, which still populates the InitializeResult.
+ async def test_default_matches_auto(self, fastmcp_server):
+ """Omitting mode= is identical to mode='auto': modern via server/discover."""
+ async with Client(fastmcp_server) as client:
+ assert client.protocol_version == LATEST_MODERN_VERSION
+ assert client.initialize_result is None
- FastMCP always serves both eras, so this is characterized against the
- real dual-era server: auto reaches modern here. The fallback denylist
- itself is exercised by the SDK's own ``negotiate_auto`` suite; this cell
- documents the FastMCP-observable outcome.
+ async def test_auto_reaches_modern_for_dual_era_server(self):
+ """FastMCP always serves both eras, so auto reaches modern here.
+
+ The fallback denylist itself is exercised by the SDK's own
+ ``negotiate_auto`` suite; this cell documents the FastMCP-observable
+ outcome for a plain server.
"""
mcp = FastMCP("both-eras")
@@ -91,6 +100,61 @@ class TestAutoMode:
async with Client(mcp, mode="auto") as client:
assert client.protocol_version == LATEST_MODERN_VERSION
+ async def test_auto_falls_back_cleanly_when_discover_is_rejected(
+ self, fastmcp_server
+ ):
+ """A server that rejects the server/discover probe with a JSON-RPC error
+ (e.g. a non-FastMCP legacy server that doesn't implement discover) makes
+ auto fall back to the initialize handshake, cleanly — no error surfaces
+ and the legacy InitializeResult is populated.
+
+ This characterizes the FastMCP-observable outcome of the SDK's
+ denylist fallback (`negotiate_auto`): every RPC error except a
+ disjoint modern-only -32022 falls back to `initialize()`.
+ """
+
+ class _DiscoverRejectingTransport(FastMCPTransport):
+ """Wraps the in-memory transport but rejects server/discover."""
+
+ @contextlib.asynccontextmanager
+ async def connect_session(
+ self, **session_kwargs: Unpack[SessionKwargs]
+ ) -> AsyncIterator[ClientSession]:
+ async with super().connect_session(**session_kwargs) as session:
+
+ async def _reject_discover(version: str) -> dict[str, Any]:
+ raise MCPError(
+ code=METHOD_NOT_FOUND, message="Method not found"
+ )
+
+ session.send_discover = _reject_discover # ty: ignore[invalid-assignment]
+ yield session
+
+ transport = _DiscoverRejectingTransport(fastmcp_server)
+ async with Client(transport, mode="auto") as client:
+ # Fell back to the handshake: legacy version + populated InitializeResult.
+ assert client.protocol_version == LATEST_HANDSHAKE_VERSION
+ assert client.initialize_result is not None
+ result = await client.call_tool("add", {"a": 1, "b": 2})
+ assert result.data == 3
+
+ async def test_auto_uses_legacy_on_legacy_only_transport(self, fastmcp_server):
+ """A `legacy_only` transport (e.g. SSE) negotiates the handshake under auto.
+
+ SSE cannot serve the sessionless modern era, so a client with the default
+ `mode="auto"` must run the initialize handshake directly rather than
+ probing server/discover (which the FastMCP server answers even over SSE
+ but then cannot serve).
+ """
+
+ class _LegacyOnlyTransport(FastMCPTransport):
+ legacy_only = True
+
+ transport = _LegacyOnlyTransport(fastmcp_server)
+ async with Client(transport, mode="auto") as client:
+ assert client.protocol_version == LATEST_HANDSHAKE_VERSION
+ assert client.initialize_result is not None
+
class TestPinnedMode:
async def test_pinned_modern_adopts_without_probe(self, fastmcp_server):
diff --git a/tests/client/tasks/test_client_prompt_tasks.py b/tests/client/tasks/test_client_prompt_tasks.py
index 57f55e0a3..069c44c3b 100644
--- a/tests/client/tasks/test_client_prompt_tasks.py
+++ b/tests/client/tasks/test_client_prompt_tasks.py
@@ -31,7 +31,7 @@ async def prompt_server():
async def test_get_prompt_as_task_returns_prompt_task(prompt_server):
"""get_prompt with task=True returns a PromptTask object."""
- async with Client(prompt_server) as client:
+ async with Client(prompt_server, mode="legacy") as client:
task = await client.get_prompt("analysis_prompt", {"topic": "AI"}, task=True)
assert isinstance(task, PromptTask)
@@ -40,7 +40,7 @@ async def test_get_prompt_as_task_returns_prompt_task(prompt_server):
async def test_prompt_task_server_generated_id(prompt_server):
"""get_prompt with task=True gets server-generated task ID."""
- async with Client(prompt_server) as client:
+ async with Client(prompt_server, mode="legacy") as client:
task = await client.get_prompt(
"creative_prompt",
{"theme": "future"},
@@ -62,7 +62,7 @@ async def test_prompt_task_server_generated_id(prompt_server):
)
async def test_prompt_task_result_returns_get_prompt_result(prompt_server):
"""PromptTask.result() returns GetPromptResult."""
- async with Client(prompt_server) as client:
+ async with Client(prompt_server, mode="legacy") as client:
task = await client.get_prompt(
"analysis_prompt", {"topic": "Robotics", "style": "casual"}, task=True
)
@@ -83,7 +83,7 @@ async def test_prompt_task_result_returns_get_prompt_result(prompt_server):
async def test_prompt_task_await_syntax(prompt_server):
"""PromptTask can be awaited directly."""
- async with Client(prompt_server) as client:
+ async with Client(prompt_server, mode="legacy") as client:
task = await client.get_prompt("creative_prompt", {"theme": "ocean"}, task=True)
# Can await task directly
@@ -93,7 +93,7 @@ async def test_prompt_task_await_syntax(prompt_server):
async def test_prompt_task_status_and_wait(prompt_server):
"""PromptTask supports status() and wait() methods."""
- async with Client(prompt_server) as client:
+ async with Client(prompt_server, mode="legacy") as client:
task = await client.get_prompt("analysis_prompt", {"topic": "Space"}, task=True)
# Check status
diff --git a/tests/client/tasks/test_client_resource_tasks.py b/tests/client/tasks/test_client_resource_tasks.py
index 0dda0366f..44ab5f826 100644
--- a/tests/client/tasks/test_client_resource_tasks.py
+++ b/tests/client/tasks/test_client_resource_tasks.py
@@ -31,7 +31,7 @@ async def resource_server():
async def test_read_resource_as_task_returns_resource_task(resource_server):
"""read_resource with task=True returns a ResourceTask object."""
- async with Client(resource_server) as client:
+ async with Client(resource_server, mode="legacy") as client:
task = await client.read_resource("file://document.txt", task=True)
assert isinstance(task, ResourceTask)
@@ -40,7 +40,7 @@ async def test_read_resource_as_task_returns_resource_task(resource_server):
async def test_resource_task_server_generated_id(resource_server):
"""read_resource with task=True gets server-generated task ID."""
- async with Client(resource_server) as client:
+ async with Client(resource_server, mode="legacy") as client:
task = await client.read_resource("file://document.txt", task=True)
# Server should generate a UUID task ID
@@ -58,7 +58,7 @@ async def test_resource_task_server_generated_id(resource_server):
)
async def test_resource_task_result_returns_read_resource_result(resource_server):
"""ResourceTask.result() returns list of ReadResourceContents."""
- async with Client(resource_server) as client:
+ async with Client(resource_server, mode="legacy") as client:
task = await client.read_resource("file://document.txt", task=True)
# Verify background execution
@@ -75,7 +75,7 @@ async def test_resource_task_result_returns_read_resource_result(resource_server
async def test_resource_task_await_syntax(resource_server):
"""ResourceTask can be awaited directly."""
- async with Client(resource_server) as client:
+ async with Client(resource_server, mode="legacy") as client:
task = await client.read_resource("file://document.txt", task=True)
# Can await task directly
@@ -91,7 +91,7 @@ async def test_resource_task_await_syntax(resource_server):
)
async def test_resource_template_task(resource_server):
"""Resource templates work with task support."""
- async with Client(resource_server) as client:
+ async with Client(resource_server, mode="legacy") as client:
task = await client.read_resource("file://data/999.json", task=True)
# Verify background execution
@@ -104,7 +104,7 @@ async def test_resource_template_task(resource_server):
async def test_resource_task_status_and_wait(resource_server):
"""ResourceTask supports status() and wait() methods."""
- async with Client(resource_server) as client:
+ async with Client(resource_server, mode="legacy") as client:
task = await client.read_resource("file://document.txt", task=True)
# Check status
diff --git a/tests/client/tasks/test_client_task_notifications.py b/tests/client/tasks/test_client_task_notifications.py
index 763622fa2..fc32d9128 100644
--- a/tests/client/tasks/test_client_task_notifications.py
+++ b/tests/client/tasks/test_client_task_notifications.py
@@ -48,7 +48,7 @@ async def task_notification_server():
async def test_task_receives_status_notification(task_notification_server):
"""Task object receives and processes status notifications."""
- async with Client(task_notification_server) as client:
+ async with Client(task_notification_server, mode="legacy") as client:
task = await client.call_tool("quick_task", {"value": 5}, task=True)
# Wait for task to complete (notification should arrive)
@@ -60,7 +60,7 @@ async def test_task_receives_status_notification(task_notification_server):
async def test_status_cache_updated_by_notification(task_notification_server):
"""Cached status is updated when notification arrives."""
- async with Client(task_notification_server) as client:
+ async with Client(task_notification_server, mode="legacy") as client:
task = await client.call_tool("quick_task", {"value": 10}, task=True)
# Wait for completion (notification should update cache)
@@ -84,7 +84,7 @@ async def test_callback_invoked_on_notification(task_notification_server):
"""Sync callback."""
callback_invocations.append(status)
- async with Client(task_notification_server) as client:
+ async with Client(task_notification_server, mode="legacy") as client:
task = await client.call_tool("quick_task", {"value": 7}, task=True)
# Register callback
@@ -113,7 +113,7 @@ async def test_async_callback_invoked(task_notification_server):
await asyncio.sleep(0.01) # Simulate async work
callback_invocations.append(status)
- async with Client(task_notification_server) as client:
+ async with Client(task_notification_server, mode="legacy") as client:
task = await client.call_tool("quick_task", {"value": 3}, task=True)
# Register async callback
@@ -140,7 +140,7 @@ async def test_multiple_callbacks_all_invoked(task_notification_server):
def callback2(status: GetTaskResult):
callback2_calls.append(status.status)
- async with Client(task_notification_server) as client:
+ async with Client(task_notification_server, mode="legacy") as client:
task = await client.call_tool("quick_task", {"value": 8}, task=True)
task.on_status_change(callback1)
@@ -166,7 +166,7 @@ async def test_callback_error_doesnt_break_notification(task_notification_server
def working_callback(status: GetTaskResult):
callback2_calls.append(status.status)
- async with Client(task_notification_server) as client:
+ async with Client(task_notification_server, mode="legacy") as client:
task = await client.call_tool("quick_task", {"value": 12}, task=True)
task.on_status_change(failing_callback)
@@ -184,7 +184,7 @@ async def test_callback_error_doesnt_break_notification(task_notification_server
async def test_wait_wakes_early_on_notification(task_notification_server):
"""wait() wakes up immediately when notification arrives, not after poll interval."""
- async with Client(task_notification_server) as client:
+ async with Client(task_notification_server, mode="legacy") as client:
task = await client.call_tool("quick_task", {"value": 15}, task=True)
# Record timing
@@ -201,7 +201,7 @@ async def test_wait_wakes_early_on_notification(task_notification_server):
async def test_notification_with_failed_task(task_notification_server):
"""Notifications work for failed tasks too."""
- async with Client(task_notification_server) as client:
+ async with Client(task_notification_server, mode="legacy") as client:
task = await client.call_tool("failing_task", {}, task=True)
with pytest.raises(Exception):
@@ -232,7 +232,7 @@ async def test_fast_task_completion_delivered_via_notification(
"""
received: list[str] = []
- async with Client(task_notification_server) as client:
+ async with Client(task_notification_server, mode="legacy") as client:
task = await client.call_tool("instant_task", {"value": 21}, task=True)
task.on_status_change(lambda status: received.append(status.status))
@@ -247,7 +247,7 @@ async def test_fast_task_completion_delivered_via_notification(
async def test_wait_returns_on_input_required(task_notification_server):
"""wait() should return immediately when task enters input_required, not hang."""
- async with Client(task_notification_server) as client:
+ async with Client(task_notification_server, mode="legacy") as client:
task = await client.call_tool("quick_task", {"value": 1}, task=True)
# Directly inject an input_required status into the cache and signal the event.
diff --git a/tests/client/tasks/test_client_task_protocol.py b/tests/client/tasks/test_client_task_protocol.py
index e8b29afd9..343e69bf4 100644
--- a/tests/client/tasks/test_client_task_protocol.py
+++ b/tests/client/tasks/test_client_task_protocol.py
@@ -24,7 +24,7 @@ async def test_end_to_end_task_flow():
await complete_signal.wait()
return f"Processed: {message}"
- async with Client(mcp) as client:
+ async with Client(mcp, mode="legacy") as client:
# Submit task
task = await client.call_tool(
"controlled_tool", {"message": "integration test"}, task=True
@@ -53,7 +53,7 @@ async def test_multiple_concurrent_tasks():
async def multiply(a: int, b: int) -> int:
return a * b
- async with Client(mcp) as client:
+ async with Client(mcp, mode="legacy") as client:
# Submit multiple tasks
tasks = []
for i in range(5):
@@ -74,7 +74,7 @@ async def test_task_id_auto_generation():
async def echo(message: str) -> str:
return f"Echo: {message}"
- async with Client(mcp) as client:
+ async with Client(mcp, mode="legacy") as client:
# Submit without custom task ID
task_1 = await client.call_tool("echo", {"message": "first"}, task=True)
task_2 = await client.call_tool("echo", {"message": "second"}, task=True)
diff --git a/tests/client/tasks/test_client_tool_tasks.py b/tests/client/tasks/test_client_tool_tasks.py
index 0bec286cb..2bccedccb 100644
--- a/tests/client/tasks/test_client_tool_tasks.py
+++ b/tests/client/tasks/test_client_tool_tasks.py
@@ -33,7 +33,7 @@ async def tool_task_server():
async def test_call_tool_as_task_returns_tool_task(tool_task_server):
"""call_tool with task=True returns a ToolTask object."""
- async with Client(tool_task_server) as client:
+ async with Client(tool_task_server, mode="legacy") as client:
task = await client.call_tool("echo", {"message": "hello"}, task=True)
assert isinstance(task, ToolTask)
@@ -43,7 +43,7 @@ async def test_call_tool_as_task_returns_tool_task(tool_task_server):
async def test_tool_task_server_generated_id(tool_task_server):
"""call_tool with task=True gets server-generated task ID."""
- async with Client(tool_task_server) as client:
+ async with Client(tool_task_server, mode="legacy") as client:
task = await client.call_tool("echo", {"message": "test"}, task=True)
# Server should generate a UUID task ID
@@ -55,7 +55,7 @@ async def test_tool_task_server_generated_id(tool_task_server):
async def test_tool_task_result_returns_call_tool_result(tool_task_server):
"""ToolTask.result() returns CallToolResult with tool data."""
- async with Client(tool_task_server) as client:
+ async with Client(tool_task_server, mode="legacy") as client:
task = await client.call_tool("multiply", {"a": 6, "b": 7}, task=True)
assert not task.returned_immediately
@@ -65,7 +65,7 @@ async def test_tool_task_result_returns_call_tool_result(tool_task_server):
async def test_tool_task_await_syntax(tool_task_server):
"""Tool tasks can be awaited directly to get result."""
- async with Client(tool_task_server) as client:
+ async with Client(tool_task_server, mode="legacy") as client:
task = await client.call_tool("multiply", {"a": 7, "b": 6}, task=True)
# Can await task directly (syntactic sugar for task.result())
@@ -75,7 +75,7 @@ async def test_tool_task_await_syntax(tool_task_server):
async def test_tool_task_status_and_wait(tool_task_server):
"""ToolTask.status() returns GetTaskResult."""
- async with Client(tool_task_server) as client:
+ async with Client(tool_task_server, mode="legacy") as client:
task = await client.call_tool("echo", {"message": "test"}, task=True)
status = await task.status()
@@ -96,7 +96,7 @@ async def test_immediate_tool_task_respects_raise_on_error_true():
def failing_tool() -> str:
raise ValueError("immediate task failure")
- async with Client(mcp) as client:
+ async with Client(mcp, mode="legacy") as client:
task = await client.call_tool("failing_tool", task=True, raise_on_error=True)
assert task.returned_immediately
@@ -114,7 +114,7 @@ async def test_immediate_tool_task_respects_raise_on_error_false():
def failing_tool() -> str:
raise ValueError("immediate task failure")
- async with Client(mcp) as client:
+ async with Client(mcp, mode="legacy") as client:
task = await client.call_tool("failing_tool", task=True, raise_on_error=False)
assert task.returned_immediately
@@ -131,7 +131,7 @@ async def test_background_tool_task_respects_raise_on_error_true():
async def failing_tool() -> str:
raise ValueError("background task failure")
- async with Client(mcp) as client:
+ async with Client(mcp, mode="legacy") as client:
task = await client.call_tool("failing_tool", task=True, raise_on_error=True)
assert not task.returned_immediately
@@ -147,7 +147,7 @@ async def test_background_tool_task_respects_raise_on_error_false():
async def failing_tool() -> str:
raise ValueError("background task failure")
- async with Client(mcp) as client:
+ async with Client(mcp, mode="legacy") as client:
task = await client.call_tool("failing_tool", task=True, raise_on_error=False)
assert not task.returned_immediately
diff --git a/tests/client/tasks/test_task_context_validation.py b/tests/client/tasks/test_task_context_validation.py
index fb4765e52..2b6a76832 100644
--- a/tests/client/tasks/test_task_context_validation.py
+++ b/tests/client/tasks/test_task_context_validation.py
@@ -37,7 +37,7 @@ async def task_server():
async def test_task_status_outside_context_raises(task_server):
"""Calling task.status() outside client context raises error."""
task = None
- async with Client(task_server) as client:
+ async with Client(task_server, mode="legacy") as client:
task = await client.call_tool("background_tool", {"value": "test"}, task=True)
assert not task.returned_immediately
# Now outside context
@@ -49,7 +49,7 @@ async def test_task_status_outside_context_raises(task_server):
async def test_task_result_outside_context_raises(task_server):
"""Calling task.result() outside context raises error."""
task = None
- async with Client(task_server) as client:
+ async with Client(task_server, mode="legacy") as client:
task = await client.call_tool("background_tool", {"value": "test"}, task=True)
assert not task.returned_immediately
# Now outside context
@@ -61,7 +61,7 @@ async def test_task_result_outside_context_raises(task_server):
async def test_task_wait_outside_context_raises(task_server):
"""Calling task.wait() outside context raises error."""
task = None
- async with Client(task_server) as client:
+ async with Client(task_server, mode="legacy") as client:
task = await client.call_tool("background_tool", {"value": "test"}, task=True)
assert not task.returned_immediately
# Now outside context
@@ -73,7 +73,7 @@ async def test_task_wait_outside_context_raises(task_server):
async def test_task_cancel_outside_context_raises(task_server):
"""Calling task.cancel() outside context raises error."""
task = None
- async with Client(task_server) as client:
+ async with Client(task_server, mode="legacy") as client:
task = await client.call_tool("background_tool", {"value": "test"}, task=True)
assert not task.returned_immediately
# Now outside context
@@ -85,7 +85,7 @@ async def test_task_cancel_outside_context_raises(task_server):
async def test_cached_tool_task_accessible_outside_context(task_server):
"""Tool tasks with cached results work outside context."""
task = None
- async with Client(task_server) as client:
+ async with Client(task_server, mode="legacy") as client:
task = await client.call_tool("background_tool", {"value": "test"}, task=True)
assert not task.returned_immediately
@@ -109,7 +109,7 @@ async def test_cached_tool_task_accessible_outside_context(task_server):
async def test_cached_prompt_task_accessible_outside_context(task_server):
"""Prompt tasks with cached results work outside context."""
task = None
- async with Client(task_server) as client:
+ async with Client(task_server, mode="legacy") as client:
task = await client.get_prompt(
"background_prompt", {"topic": "test"}, task=True
)
@@ -135,7 +135,7 @@ async def test_cached_prompt_task_accessible_outside_context(task_server):
async def test_cached_resource_task_accessible_outside_context(task_server):
"""Resource tasks with cached results work outside context."""
task = None
- async with Client(task_server) as client:
+ async with Client(task_server, mode="legacy") as client:
task = await client.read_resource("file://background.txt", task=True)
assert not task.returned_immediately
@@ -152,7 +152,7 @@ async def test_cached_resource_task_accessible_outside_context(task_server):
async def test_uncached_status_outside_context_raises(task_server):
"""Even after caching result, status() still requires client context."""
task = None
- async with Client(task_server) as client:
+ async with Client(task_server, mode="legacy") as client:
task = await client.call_tool("background_tool", {"value": "test"}, task=True)
assert not task.returned_immediately
@@ -172,7 +172,7 @@ async def test_uncached_status_outside_context_raises(task_server):
async def test_task_await_syntax_outside_context_raises(task_server):
"""Using await task syntax outside context raises error for background tasks."""
task = None
- async with Client(task_server) as client:
+ async with Client(task_server, mode="legacy") as client:
task = await client.call_tool("background_tool", {"value": "test"}, task=True)
assert not task.returned_immediately
# Now outside context
@@ -184,7 +184,7 @@ async def test_task_await_syntax_outside_context_raises(task_server):
async def test_task_await_syntax_works_for_cached_results(task_server):
"""Using await task syntax works outside context when result is cached."""
task = None
- async with Client(task_server) as client:
+ async with Client(task_server, mode="legacy") as client:
task = await client.call_tool("background_tool", {"value": "test"}, task=True)
result1 = await task # Cache it
# Now outside context
@@ -196,7 +196,7 @@ async def test_task_await_syntax_works_for_cached_results(task_server):
async def test_multiple_result_calls_return_same_cached_object(task_server):
"""Multiple result() calls return the same cached object."""
- async with Client(task_server) as client:
+ async with Client(task_server, mode="legacy") as client:
task = await client.call_tool("background_tool", {"value": "test"}, task=True)
result1 = await task.result()
@@ -211,7 +211,7 @@ async def test_multiple_result_calls_return_same_cached_object(task_server):
async def test_background_task_properties_accessible_outside_context(task_server):
"""Background task properties like task_id accessible outside context."""
task = None
- async with Client(task_server) as client:
+ async with Client(task_server, mode="legacy") as client:
task = await client.call_tool("background_tool", {"value": "test"}, task=True)
task_id_inside = task.task_id
assert not task.returned_immediately
diff --git a/tests/client/tasks/test_task_result_caching.py b/tests/client/tasks/test_task_result_caching.py
index fdb48e129..183014c8e 100644
--- a/tests/client/tasks/test_task_result_caching.py
+++ b/tests/client/tasks/test_task_result_caching.py
@@ -22,7 +22,7 @@ async def test_tool_task_result_cached_on_first_call():
call_count += 1
return call_count
- async with Client(mcp) as client:
+ async with Client(mcp, mode="legacy") as client:
task = await client.call_tool("counting_tool", task=True)
result1 = await task.result()
@@ -49,7 +49,7 @@ async def test_prompt_task_result_cached():
call_count += 1
return f"Call number: {call_count}"
- async with Client(mcp) as client:
+ async with Client(mcp, mode="legacy") as client:
task = await client.get_prompt("counting_prompt", task=True)
result1 = await task.result()
@@ -76,7 +76,7 @@ async def test_resource_task_result_cached():
call_count += 1
return f"Count: {call_count}"
- async with Client(mcp) as client:
+ async with Client(mcp, mode="legacy") as client:
task = await client.read_resource("file://counter.txt", task=True)
result1 = await task.result()
@@ -100,7 +100,7 @@ async def test_multiple_await_returns_same_object():
async def sample_tool() -> str:
return "result"
- async with Client(mcp) as client:
+ async with Client(mcp, mode="legacy") as client:
task = await client.call_tool("sample_tool", task=True)
result1 = await task
@@ -120,7 +120,7 @@ async def test_result_and_await_share_cache():
async def sample_tool() -> str:
return "cached"
- async with Client(mcp) as client:
+ async with Client(mcp, mode="legacy") as client:
task = await client.call_tool("sample_tool", task=True)
# Call result() first
@@ -142,7 +142,7 @@ async def test_forbidden_mode_tool_caches_error_result():
async def non_task_tool() -> int:
return 1
- async with Client(mcp) as client:
+ async with Client(mcp, mode="legacy") as client:
# Request as task, but mode="forbidden" will reject with error
task = await client.call_tool("non_task_tool", task=True, raise_on_error=False)
@@ -178,7 +178,7 @@ async def test_forbidden_mode_prompt_raises_error():
async def non_task_prompt() -> str:
return "Immediate"
- async with Client(mcp) as client:
+ async with Client(mcp, mode="legacy") as client:
# Prompts with mode="forbidden" raise MCPError when called with task=True
with pytest.raises(MCPError):
await client.get_prompt("non_task_prompt", task=True)
@@ -201,7 +201,7 @@ async def test_forbidden_mode_resource_raises_error():
async def non_task_resource() -> str:
return "Immediate"
- async with Client(mcp) as client:
+ async with Client(mcp, mode="legacy") as client:
# Resources with mode="forbidden" raise MCPError when called with task=True
with pytest.raises(MCPError):
await client.read_resource("file://immediate.txt", task=True)
@@ -219,7 +219,7 @@ async def test_immediate_task_caches_result():
call_count += 1
return call_count
- async with Client(mcp) as client:
+ async with Client(mcp, mode="legacy") as client:
# Call with task=True
task = await client.call_tool("task_tool", task=True)
@@ -245,7 +245,7 @@ async def test_cache_persists_across_mixed_access_patterns():
async def mixed_tool() -> str:
return "mixed"
- async with Client(mcp) as client:
+ async with Client(mcp, mode="legacy") as client:
task = await client.call_tool("mixed_tool", task=True)
# Access in various orders
@@ -266,7 +266,7 @@ async def test_different_tasks_have_separate_caches():
async def separate_tool(value: str) -> str:
return f"Result: {value}"
- async with Client(mcp) as client:
+ async with Client(mcp, mode="legacy") as client:
task1 = await client.call_tool("separate_tool", {"value": "A"}, task=True)
task2 = await client.call_tool("separate_tool", {"value": "B"}, task=True)
@@ -296,7 +296,7 @@ async def test_cache_survives_status_checks():
async def status_check_tool() -> str:
return "status"
- async with Client(mcp) as client:
+ async with Client(mcp, mode="legacy") as client:
task = await client.call_tool("status_check_tool", task=True)
# Check status multiple times
@@ -322,7 +322,7 @@ async def test_cache_survives_wait_calls():
async def wait_test_tool() -> str:
return "waited"
- async with Client(mcp) as client:
+ async with Client(mcp, mode="legacy") as client:
task = await client.call_tool("wait_test_tool", task=True)
# Wait for completion
diff --git a/tests/client/telemetry/test_client_task_tracing.py b/tests/client/telemetry/test_client_task_tracing.py
index 4d0b71718..9d96985f7 100644
--- a/tests/client/telemetry/test_client_task_tracing.py
+++ b/tests/client/telemetry/test_client_task_tracing.py
@@ -52,7 +52,7 @@ async def test_list_tasks_creates_propagating_client_span(
):
server = FastMCP("test-server")
- async with Client(server) as client:
+ async with Client(server, mode="legacy") as client:
await client.list_tasks()
assert_propagating_client_span(trace_exporter, "tasks/list", "")
@@ -74,7 +74,7 @@ async def test_task_id_operations_create_propagating_client_spans(
await asyncio.sleep(10)
return "done"
- async with Client(server) as client:
+ async with Client(server, mode="legacy") as client:
completed_task = await client.call_tool("quick_tool", task=True)
await completed_task.wait(timeout=2)
trace_exporter.clear()
diff --git a/tests/client/telemetry/test_client_tracing.py b/tests/client/telemetry/test_client_tracing.py
index 1180a40dc..0ca2e8759 100644
--- a/tests/client/telemetry/test_client_tracing.py
+++ b/tests/client/telemetry/test_client_tracing.py
@@ -589,7 +589,7 @@ class TestSessionIdOnSpans:
from fastmcp.client.transports import StreamableHttpTransport
transport = StreamableHttpTransport(http_server_url)
- client = Client(transport=transport)
+ client = Client(transport=transport, mode="legacy")
async with client:
await client.call_tool("echo", {"message": "test"})
@@ -657,7 +657,7 @@ class TestSessionIdOnSpans:
from fastmcp.client.transports import StreamableHttpTransport
transport = StreamableHttpTransport(http_server_url)
- client = Client(transport=transport)
+ client = Client(transport=transport, mode="legacy")
async with client:
await client.call_tool("echo", {"message": "test"})
diff --git a/tests/client/test_client_extensions.py b/tests/client/test_client_extensions.py
new file mode 100644
index 000000000..87c29b994
--- /dev/null
+++ b/tests/client/test_client_extensions.py
@@ -0,0 +1,331 @@
+"""Tests for surfacing SEP-2133 client extensions on ``fastmcp.Client``.
+
+Covers that ``extensions=`` / ``result_claims=`` are folded into the underlying
+``ClientSession`` kwargs on construction, that user-supplied notification
+bindings *compose* with FastMCP's internal task-status binding rather than
+clobbering it, that both bindings actually fire against a live server, and that
+a claimed ``tools/call`` result is resolved end-to-end through the owning
+extension's resolver.
+"""
+
+import asyncio
+from typing import Any, Literal
+
+import pytest
+from mcp.client.extension import (
+ ClaimContext,
+ ClientExtension,
+ NotificationBinding,
+ ResultClaim,
+ UnexpectedClaimedResult,
+)
+from mcp.server.context import CallNext, HandlerResult, ServerRequestContext
+from mcp.server.extension import Extension
+from mcp.server.mcpserver import MCPServer as SDKServer
+from mcp_types import CallToolRequestParams, CallToolResult, Result, TextContent
+from mcp_types.version import LATEST_MODERN_VERSION
+from pydantic import BaseModel
+
+from fastmcp import FastMCP
+from fastmcp.client import Client
+from fastmcp.server.dependencies import get_context
+
+CUSTOM_METHOD = "notifications/x-test/ping"
+TASK_STATUS_METHOD = "notifications/tasks/status"
+EXTENSION_ID = "test.example.com/demo"
+CLAIMED_TYPE = "x-test/claimed"
+
+
+class PingParams(BaseModel):
+ value: int = 0
+
+
+class ClaimedResult(Result):
+ result_type: Literal["x-test/claimed"]
+ payload: str = ""
+
+
+async def _resolve_claimed(result: ClaimedResult, ctx: ClaimContext) -> CallToolResult:
+ """Finish a claimed result into an ordinary CallToolResult.
+
+ Echoes the claimed payload so a test can prove the resolver ran on the
+ server-emitted value rather than a placeholder.
+ """
+ return CallToolResult(
+ content=[TextContent(type="text", text=f"resolved:{result.payload}")]
+ )
+
+
+def _make_claim() -> ResultClaim[ClaimedResult]:
+ return ResultClaim(
+ result_type=CLAIMED_TYPE,
+ model=ClaimedResult,
+ resolve=_resolve_claimed,
+ )
+
+
+class _DemoExtension(ClientExtension):
+ """Extension contributing a settings ad, a result claim, and a binding."""
+
+ identifier = EXTENSION_ID
+
+ def __init__(self, received: list[PingParams] | None = None) -> None:
+ self._received = received if received is not None else []
+
+ def settings(self) -> dict[str, Any]:
+ return {"enabled": True}
+
+ def claims(self):
+ return (_make_claim(),)
+
+ def notifications(self):
+ async def _handler(params: PingParams) -> None:
+ self._received.append(params)
+
+ return (
+ NotificationBinding(
+ method=CUSTOM_METHOD,
+ params_type=PingParams,
+ handler=_handler,
+ ),
+ )
+
+
+class _ServerClaimExtension(Extension):
+ """Server-side extension that answers a specific tool with a claimed shape."""
+
+ identifier = EXTENSION_ID
+
+ async def intercept_tool_call(
+ self,
+ params: CallToolRequestParams,
+ ctx: ServerRequestContext[Any, Any],
+ call_next: CallNext,
+ ) -> HandlerResult:
+ if params.name == "claimed_tool":
+ return ClaimedResult(result_type=CLAIMED_TYPE, payload="from-server")
+ return await call_next(ctx)
+
+
+def _claiming_server() -> SDKServer:
+ """An SDK MCPServer whose `claimed_tool` returns a claimed extension result."""
+ server = SDKServer("claim-server", extensions=[_ServerClaimExtension()])
+
+ # No return annotation → no output schema, so the resolved CallToolResult
+ # (plain text, no structured content) passes revalidation.
+ @server.tool()
+ def claimed_tool():
+ return None
+
+ return server
+
+
+def _binding_methods(client: Client) -> list[str]:
+ bindings = client._session_kwargs.get("notification_bindings") or []
+ return [b.method for b in bindings]
+
+
+def test_extension_folds_into_session_kwargs():
+ """A ClientExtension's ad, claim, and binding reach the session kwargs."""
+ client = Client(FastMCP("srv"), extensions=[_DemoExtension()])
+
+ assert client._session_kwargs.get("extensions") == {EXTENSION_ID: {"enabled": True}}
+ result_claims = client._session_kwargs.get("result_claims")
+ assert result_claims is not None
+ assert [c.result_type for c in result_claims[EXTENSION_ID]] == [CLAIMED_TYPE]
+
+
+def test_extension_populates_claim_by_model_index():
+ """The claim is indexed by its model so the resolution path can find it."""
+ client = Client(FastMCP("srv"), extensions=[_DemoExtension()])
+
+ assert client._claim_by_model[ClaimedResult].result_type == CLAIMED_TYPE
+
+
+def test_binding_composes_with_internal_task_binding():
+ """User binding is appended to (not replacing) the task-status binding."""
+ client = Client(FastMCP("srv"), extensions=[_DemoExtension()])
+
+ methods = _binding_methods(client)
+ assert TASK_STATUS_METHOD in methods
+ assert CUSTOM_METHOD in methods
+ # The internal task binding must lead so user bindings extend it.
+ assert methods[0] == TASK_STATUS_METHOD
+
+
+def test_no_extensions_leaves_only_task_binding():
+ """Without extensions, only the internal task-status binding is registered."""
+ client = Client(FastMCP("srv"))
+
+ assert _binding_methods(client) == [TASK_STATUS_METHOD]
+ assert "extensions" not in client._session_kwargs
+ assert "result_claims" not in client._session_kwargs
+ assert client._claim_by_model == {}
+
+
+def test_new_preserves_extension_composition():
+ """new() rebuilds the clone with both the task binding and user bindings."""
+ client = Client(FastMCP("srv"), extensions=[_DemoExtension()])
+ clone = client.new()
+
+ methods = _binding_methods(clone)
+ assert methods[0] == TASK_STATUS_METHOD
+ assert CUSTOM_METHOD in methods
+ assert clone._session_kwargs.get("extensions") == {EXTENSION_ID: {"enabled": True}}
+ assert clone._claim_by_model[ClaimedResult].result_type == CLAIMED_TYPE
+
+
+def test_result_claims_merge_with_extension_claims():
+ """Explicit result_claims merge with an advertised extension's own claims."""
+
+ class ExtraClaimed(Result):
+ result_type: Literal["x-test/extra"]
+
+ async def _resolve_extra(result: ExtraClaimed, ctx: ClaimContext) -> CallToolResult:
+ return CallToolResult(content=[])
+
+ extra_claim = ResultClaim(
+ result_type="x-test/extra",
+ model=ExtraClaimed,
+ resolve=_resolve_extra,
+ )
+
+ client = Client(
+ FastMCP("srv"),
+ extensions=[_DemoExtension()],
+ result_claims={EXTENSION_ID: [extra_claim]},
+ )
+
+ result_claims = client._session_kwargs.get("result_claims")
+ assert result_claims is not None
+ tags = {c.result_type for c in result_claims[EXTENSION_ID]}
+ assert tags == {CLAIMED_TYPE, "x-test/extra"}
+ # Both the extension claim and the explicit extra claim are resolvable.
+ assert set(client._claim_by_model) == {ClaimedResult, ExtraClaimed}
+
+
+async def test_user_binding_clobbering_task_method_is_rejected():
+ """A user extension binding the task-status method cannot silently replace it.
+
+ Composition means the internal task binding always leads; a user extension
+ that binds the same method collides with it, and the SDK session rejects the
+ duplicate at connect time rather than letting one silently win.
+ """
+
+ class TaskClobberExtension(ClientExtension):
+ identifier = "test.example.com/clobber"
+
+ def notifications(self):
+ async def _handler(params: PingParams) -> None: ...
+
+ return (
+ NotificationBinding(
+ method=TASK_STATUS_METHOD,
+ params_type=PingParams,
+ handler=_handler,
+ ),
+ )
+
+ client = Client(FastMCP("srv"), extensions=[TaskClobberExtension()])
+ with pytest.raises(RuntimeError, match="duplicate notification binding"):
+ async with client:
+ pass
+
+
+async def test_both_bindings_fire_against_live_server():
+ """The internal task binding and a user extension binding both fire.
+
+ A ``task=True`` tool drives ``notifications/tasks/status`` (the internal
+ binding) while a second tool emits a custom notification the user extension
+ observes, proving the two coexist on one live connection. Pinned to
+ ``mode="legacy"`` because FastMCP task submission is a legacy-era feature.
+ """
+ received: list[PingParams] = []
+ mcp = FastMCP("compose-server")
+
+ @mcp.tool
+ async def emit(value: int) -> int:
+ ctx = get_context()
+ # Emit a custom (non-core) notification straight onto the outbound
+ # channel; unknown methods route to the client's notification bindings.
+ await ctx.session._connection.notify(CUSTOM_METHOD, {"value": value})
+ return value
+
+ @mcp.tool(task=True)
+ async def background(value: int) -> int:
+ await asyncio.sleep(0.02)
+ return value * 2
+
+ client = Client(mcp, extensions=[_DemoExtension(received)], mode="legacy")
+
+ async with client:
+ # The user extension binding fires on the custom notification.
+ await client.call_tool("emit", {"value": 21})
+ # The internal task binding fires on the task-status notification.
+ task = await client.call_tool("background", {"value": 5}, task=True)
+ status = await task.wait(timeout=2.0)
+ # Give the custom-notification queue a moment to drain.
+ await asyncio.sleep(0.1)
+
+ # Internal task binding fired: the task completed via a status notification.
+ assert status.status == "completed"
+ # User extension binding fired: it observed the custom notification.
+ assert [p.value for p in received] == [21]
+
+
+class TestClaimedResultResolution:
+ """End-to-end resolution of a server-emitted claimed `tools/call` result."""
+
+ @pytest.mark.parametrize("mode", ["auto", LATEST_MODERN_VERSION])
+ async def test_call_tool_mcp_resolves_claimed_result(self, mode):
+ """`call_tool_mcp` resolves a claimed result through the extension resolver.
+
+ The server emits a claimed shape; the client's registered extension
+ parses it and its resolver finishes it into an ordinary CallToolResult.
+ Both the negotiated (`auto`) and pinned modern eras admit the claim.
+ """
+ client = Client(_claiming_server(), extensions=[_DemoExtension()], mode=mode)
+ async with client:
+ assert client.protocol_version == LATEST_MODERN_VERSION
+ result = await client.call_tool_mcp("claimed_tool", {})
+
+ block = result.content[0]
+ assert isinstance(block, TextContent)
+ assert block.text == "resolved:from-server"
+
+ async def test_call_tool_resolves_claimed_result(self):
+ """The high-level `call_tool` also returns the resolver's CallToolResult."""
+ client = Client(
+ _claiming_server(),
+ extensions=[_DemoExtension()],
+ mode=LATEST_MODERN_VERSION,
+ )
+ async with client:
+ parsed = await client.call_tool("claimed_tool", {})
+
+ block = parsed.content[0]
+ assert isinstance(block, TextContent)
+ assert block.text == "resolved:from-server"
+
+ async def test_unwired_session_call_raises_unexpected_claimed(self):
+ """Regression guard for the half-wired bug: the raw session path raises.
+
+ With the claim registered, calling `session.call_tool` directly (FastMCP's
+ old tool path, which omitted `allow_claimed=True`) surfaces the claimed
+ result as `UnexpectedClaimedResult` — the exact failure the wired
+ `call_tool_mcp` path now avoids by resolving instead.
+ """
+ client = Client(
+ _claiming_server(),
+ extensions=[_DemoExtension()],
+ mode=LATEST_MODERN_VERSION,
+ )
+ async with client:
+ with pytest.raises(UnexpectedClaimedResult):
+ await client.session.call_tool("claimed_tool", {})
+
+ # The wired path resolves the very same claimed result.
+ resolved = await client.call_tool_mcp("claimed_tool", {})
+ block = resolved.content[0]
+ assert isinstance(block, TextContent)
+ assert block.text == "resolved:from-server"
diff --git a/tests/client/test_elicitation.py b/tests/client/test_elicitation.py
index 2686edb79..c0c406dd2 100644
--- a/tests/client/test_elicitation.py
+++ b/tests/client/test_elicitation.py
@@ -51,7 +51,7 @@ def fastmcp_server():
async def test_elicitation_with_no_handler(fastmcp_server):
"""Test that elicitation works without a handler."""
- async with Client(fastmcp_server) as client:
+ async with Client(fastmcp_server, mode="legacy") as client:
with pytest.raises(ToolError, match="Elicitation not supported"):
await client.call_tool("ask_for_name")
@@ -64,7 +64,7 @@ async def test_elicitation_accept_content(fastmcp_server):
return ElicitResult(action="accept", content=response_type(name="Alice"))
async with Client(
- fastmcp_server, elicitation_handler=elicitation_handler
+ fastmcp_server, mode="legacy", elicitation_handler=elicitation_handler
) as client:
result = await client.call_tool("ask_for_name")
assert result.data == "Hello, Alice!"
@@ -77,7 +77,7 @@ async def test_elicitation_decline(fastmcp_server):
return ElicitResult(action="decline")
async with Client(
- fastmcp_server, elicitation_handler=elicitation_handler
+ fastmcp_server, mode="legacy", elicitation_handler=elicitation_handler
) as client:
result = await client.call_tool("ask_for_name")
assert result.data == "No name provided."
@@ -103,7 +103,9 @@ async def test_elicitation_handler_parameters():
captured_params["ctx"] = ctx
return ElicitResult(action="accept", content={"value": 42})
- async with Client(mcp, elicitation_handler=elicitation_handler) as client:
+ async with Client(
+ mcp, mode="legacy", elicitation_handler=elicitation_handler
+ ) as client:
await client.call_tool("test_tool", {})
assert captured_params["message"] == "Test message"
@@ -136,7 +138,9 @@ async def test_elicitation_response_title_and_description_on_scalar():
captured_schema.update(params.requested_schema)
return ElicitResult(action="accept", content={"value": True})
- async with Client(mcp, elicitation_handler=elicitation_handler) as client:
+ async with Client(
+ mcp, mode="legacy", elicitation_handler=elicitation_handler
+ ) as client:
await client.call_tool("confirm_purchase", {})
assert captured_schema["properties"]["value"]["title"] == "Confirm purchase"
@@ -165,7 +169,9 @@ async def test_elicitation_response_title_on_dict_shorthand():
captured_schema.update(params.requested_schema)
return ElicitResult(action="accept", content={"value": "low"})
- async with Client(mcp, elicitation_handler=elicitation_handler) as client:
+ async with Client(
+ mcp, mode="legacy", elicitation_handler=elicitation_handler
+ ) as client:
await client.call_tool("pick_priority", {})
assert captured_schema["properties"]["value"]["title"] == "Priority level"
@@ -189,7 +195,9 @@ async def test_elicitation_response_title_on_list_shorthand():
captured_schema.update(params.requested_schema)
return ElicitResult(action="accept", content={"value": "red"})
- async with Client(mcp, elicitation_handler=elicitation_handler) as client:
+ async with Client(
+ mcp, mode="legacy", elicitation_handler=elicitation_handler
+ ) as client:
await client.call_tool("pick_color", {})
assert captured_schema["properties"]["value"]["title"] == "Favorite color"
@@ -214,7 +222,9 @@ async def test_elicitation_response_title_rejected_for_basemodel():
async def elicitation_handler(message, response_type, params, ctx):
return ElicitResult(action="accept", content={"name": "x"})
- async with Client(mcp, elicitation_handler=elicitation_handler) as client:
+ async with Client(
+ mcp, mode="legacy", elicitation_handler=elicitation_handler
+ ) as client:
with pytest.raises(ToolError, match="response_title"):
await client.call_tool("ask", {})
@@ -235,7 +245,9 @@ async def test_elicitation_response_title_rejected_for_none():
async def elicitation_handler(message, response_type, params, ctx):
return ElicitResult(action="accept", content={})
- async with Client(mcp, elicitation_handler=elicitation_handler) as client:
+ async with Client(
+ mcp, mode="legacy", elicitation_handler=elicitation_handler
+ ) as client:
with pytest.raises(ToolError, match="response_title"):
await client.call_tool("ask", {})
@@ -261,7 +273,9 @@ async def test_elicitation_cancel_action():
async def elicitation_handler(message, response_type, params, ctx):
return ElicitResult(action="cancel")
- async with Client(mcp, elicitation_handler=elicitation_handler) as client:
+ async with Client(
+ mcp, mode="legacy", elicitation_handler=elicitation_handler
+ ) as client:
result = await client.call_tool("ask_for_optional_info", {})
assert result.data == "Request was canceled"
@@ -281,7 +295,9 @@ class TestScalarResponseTypes:
async def elicitation_handler(message, response_type, params, ctx):
return ElicitResult(action="accept", content="Alice")
- async with Client(mcp, elicitation_handler=elicitation_handler) as client:
+ async with Client(
+ mcp, mode="legacy", elicitation_handler=elicitation_handler
+ ) as client:
result = await client.call_tool("my_tool", {})
assert result.data == "Alice"
@@ -304,7 +320,9 @@ class TestScalarResponseTypes:
assert response_type is None
return ElicitResult(action="accept")
- async with Client(mcp, elicitation_handler=elicitation_handler) as client:
+ async with Client(
+ mcp, mode="legacy", elicitation_handler=elicitation_handler
+ ) as client:
result = await client.call_tool("my_tool", {})
assert result.data is None
@@ -326,7 +344,9 @@ class TestScalarResponseTypes:
):
return ElicitResult(action="accept", content={})
- async with Client(mcp, elicitation_handler=elicitation_handler) as client:
+ async with Client(
+ mcp, mode="legacy", elicitation_handler=elicitation_handler
+ ) as client:
result = await client.call_tool("my_tool", {})
assert result.data is None
@@ -346,7 +366,9 @@ class TestScalarResponseTypes:
async def elicitation_handler(message, response_type, params, ctx):
return ElicitResult(action="accept", content={"value": "hello"})
- async with Client(mcp, elicitation_handler=elicitation_handler) as client:
+ async with Client(
+ mcp, mode="legacy", elicitation_handler=elicitation_handler
+ ) as client:
with pytest.raises(
ToolError, match="Elicitation expected an empty response"
):
@@ -366,7 +388,9 @@ class TestScalarResponseTypes:
async def elicitation_handler(message, response_type, params, ctx):
return ElicitResult(action="accept", content={"value": "hello"})
- async with Client(mcp, elicitation_handler=elicitation_handler) as client:
+ async with Client(
+ mcp, mode="legacy", elicitation_handler=elicitation_handler
+ ) as client:
result = await client.call_tool("my_tool", {})
assert result.data == "hello"
@@ -384,7 +408,9 @@ class TestScalarResponseTypes:
async def elicitation_handler(message, response_type, params, ctx):
return ElicitResult(action="accept", content={"value": 42})
- async with Client(mcp, elicitation_handler=elicitation_handler) as client:
+ async with Client(
+ mcp, mode="legacy", elicitation_handler=elicitation_handler
+ ) as client:
result = await client.call_tool("my_tool", {})
assert result.data == 42
@@ -402,7 +428,9 @@ class TestScalarResponseTypes:
async def elicitation_handler(message, response_type, params, ctx):
return ElicitResult(action="accept", content={"value": 3.14})
- async with Client(mcp, elicitation_handler=elicitation_handler) as client:
+ async with Client(
+ mcp, mode="legacy", elicitation_handler=elicitation_handler
+ ) as client:
result = await client.call_tool("my_tool", {})
assert result.data == 3.14
@@ -420,7 +448,9 @@ class TestScalarResponseTypes:
async def elicitation_handler(message, response_type, params, ctx):
return ElicitResult(action="accept", content={"value": True})
- async with Client(mcp, elicitation_handler=elicitation_handler) as client:
+ async with Client(
+ mcp, mode="legacy", elicitation_handler=elicitation_handler
+ ) as client:
result = await client.call_tool("my_tool", {})
assert result.data is True
@@ -440,7 +470,9 @@ class TestScalarResponseTypes:
async def elicitation_handler(message, response_type, params, ctx):
return ElicitResult(action="accept", content={"value": "x"})
- async with Client(mcp, elicitation_handler=elicitation_handler) as client:
+ async with Client(
+ mcp, mode="legacy", elicitation_handler=elicitation_handler
+ ) as client:
result = await client.call_tool("my_tool", {})
assert result.data == "x"
@@ -462,7 +494,9 @@ class TestScalarResponseTypes:
async def elicitation_handler(message, response_type, params, ctx):
return ElicitResult(action="accept", content={"value": "x"})
- async with Client(mcp, elicitation_handler=elicitation_handler) as client:
+ async with Client(
+ mcp, mode="legacy", elicitation_handler=elicitation_handler
+ ) as client:
result = await client.call_tool("my_tool", {})
assert result.data == "x"
@@ -480,7 +514,9 @@ class TestScalarResponseTypes:
async def elicitation_handler(message, response_type, params, ctx):
return ElicitResult(action="accept", content={"value": "x"})
- async with Client(mcp, elicitation_handler=elicitation_handler) as client:
+ async with Client(
+ mcp, mode="legacy", elicitation_handler=elicitation_handler
+ ) as client:
result = await client.call_tool("my_tool", {})
assert result.data == "x"
@@ -504,7 +540,9 @@ async def test_elicitation_handler_error():
async def elicitation_handler(message, response_type, params, ctx):
raise ValueError("Handler failed!")
- async with Client(mcp, elicitation_handler=elicitation_handler) as client:
+ async with Client(
+ mcp, mode="legacy", elicitation_handler=elicitation_handler
+ ) as client:
result = await client.call_tool("failing_elicit", {})
assert "Error:" in result.data
@@ -547,7 +585,9 @@ async def test_elicitation_multiple_calls():
else:
raise ValueError("Unexpected call")
- async with Client(mcp, elicitation_handler=elicitation_handler) as client:
+ async with Client(
+ mcp, mode="legacy", elicitation_handler=elicitation_handler
+ ) as client:
result = await client.call_tool("multi_step_form", {})
assert result.data == "Hello Bob, you are 25 years old"
assert call_count == 2
@@ -619,7 +659,9 @@ async def test_structured_response_type(
return ElicitResult(action="accept", content=UserInfo(name="Alice", age=30))
- async with Client(mcp, elicitation_handler=elicitation_handler) as client:
+ async with Client(
+ mcp, mode="legacy", elicitation_handler=elicitation_handler
+ ) as client:
result = await client.call_tool("get_user_info", {})
assert result.data == "User: Alice, age: 30"
@@ -666,7 +708,9 @@ async def test_all_primitive_field_types():
),
)
- async with Client(mcp, elicitation_handler=elicitation_handler) as client:
+ async with Client(
+ mcp, mode="legacy", elicitation_handler=elicitation_handler
+ ) as client:
result = await client.call_tool("get_data", {})
# Now all literal/enum fields should be preserved as strings
@@ -746,7 +790,9 @@ class TestPatternMatching:
async def elicitation_handler(message, response_type, params, ctx):
return ElicitResult(action="accept", content={"value": "Alice"})
- async with Client(mcp, elicitation_handler=elicitation_handler) as client:
+ async with Client(
+ mcp, mode="legacy", elicitation_handler=elicitation_handler
+ ) as client:
result = await client.call_tool("pattern_match_tool", {})
assert result.data == "Hello Alice!"
@@ -771,7 +817,9 @@ class TestPatternMatching:
async def elicitation_handler(message, response_type, params, ctx):
return ElicitResult(action="decline")
- async with Client(mcp, elicitation_handler=elicitation_handler) as client:
+ async with Client(
+ mcp, mode="legacy", elicitation_handler=elicitation_handler
+ ) as client:
result = await client.call_tool("pattern_match_tool", {})
assert result.data == "You declined"
@@ -796,6 +844,8 @@ class TestPatternMatching:
async def elicitation_handler(message, response_type, params, ctx):
return ElicitResult(action="cancel")
- async with Client(mcp, elicitation_handler=elicitation_handler) as client:
+ async with Client(
+ mcp, mode="legacy", elicitation_handler=elicitation_handler
+ ) as client:
result = await client.call_tool("pattern_match_tool", {})
assert result.data == "Cancelled"
diff --git a/tests/client/test_elicitation_enums.py b/tests/client/test_elicitation_enums.py
index f1beb62d5..60c9f6ac4 100644
--- a/tests/client/test_elicitation_enums.py
+++ b/tests/client/test_elicitation_enums.py
@@ -54,7 +54,7 @@ async def test_elicitation_implicit_acceptance(fastmcp_server):
return response_type(name="Bob")
async with Client(
- fastmcp_server, elicitation_handler=elicitation_handler
+ fastmcp_server, mode="legacy", elicitation_handler=elicitation_handler
) as client:
result = await client.call_tool("ask_for_name")
assert result.data == "Hello, Bob!"
@@ -69,7 +69,7 @@ async def test_elicitation_implicit_acceptance_must_be_dict(fastmcp_server):
return "Bob"
async with Client(
- fastmcp_server, elicitation_handler=elicitation_handler
+ fastmcp_server, mode="legacy", elicitation_handler=elicitation_handler
) as client:
with pytest.raises(
ToolError,
@@ -182,7 +182,9 @@ async def test_dict_based_titled_single_select():
return ElicitResult(action="accept", content={"value": "low"})
- async with Client(mcp, elicitation_handler=elicitation_handler) as client:
+ async with Client(
+ mcp, mode="legacy", elicitation_handler=elicitation_handler
+ ) as client:
result = await client.call_tool("my_tool", {})
assert result.data == "low"
@@ -215,7 +217,9 @@ async def test_list_list_multi_select_untitled():
return ElicitResult(action="accept", content={"value": ["bug", "feature"]})
- async with Client(mcp, elicitation_handler=elicitation_handler) as client:
+ async with Client(
+ mcp, mode="legacy", elicitation_handler=elicitation_handler
+ ) as client:
result = await client.call_tool("my_tool", {})
assert result.data == "bug,feature"
@@ -256,7 +260,9 @@ async def test_list_dict_multi_select_titled():
return ElicitResult(action="accept", content={"value": ["low", "high"]})
- async with Client(mcp, elicitation_handler=elicitation_handler) as client:
+ async with Client(
+ mcp, mode="legacy", elicitation_handler=elicitation_handler
+ ) as client:
result = await client.call_tool("my_tool", {})
assert result.data == "low,high"
@@ -320,7 +326,9 @@ async def test_list_enum_multi_select_direct():
return ElicitResult(action="accept", content={"value": ["low", "high"]})
- async with Client(mcp, elicitation_handler=elicitation_handler) as client:
+ async with Client(
+ mcp, mode="legacy", elicitation_handler=elicitation_handler
+ ) as client:
result = await client.call_tool("my_tool", {})
assert result.data == "low,high"
diff --git a/tests/client/test_logs.py b/tests/client/test_logs.py
index 43b6e1d0f..34628ed4b 100644
--- a/tests/client/test_logs.py
+++ b/tests/client/test_logs.py
@@ -95,7 +95,9 @@ class TestSetLoggingLevel:
async def test_set_logging_level(self, fastmcp_server: FastMCP):
"""Client can set the minimum log level and lower-level messages are suppressed."""
log_handler = LogHandler()
- async with Client(fastmcp_server, log_handler=log_handler.handle_log) as client:
+ async with Client(
+ fastmcp_server, mode="legacy", log_handler=log_handler.handle_log
+ ) as client:
await client.set_logging_level("warning")
await client.call_tool(
"echo_log", {"message": "debug msg", "level": "debug"}
@@ -115,7 +117,9 @@ class TestSetLoggingLevel:
async def test_set_logging_level_debug_allows_all(self, fastmcp_server: FastMCP):
"""Setting level to debug allows all messages through."""
log_handler = LogHandler()
- async with Client(fastmcp_server, log_handler=log_handler.handle_log) as client:
+ async with Client(
+ fastmcp_server, mode="legacy", log_handler=log_handler.handle_log
+ ) as client:
await client.set_logging_level("debug")
await client.call_tool(
"echo_log", {"message": "debug msg", "level": "debug"}
@@ -169,7 +173,9 @@ class TestSetLoggingLevel:
await context.log(message=message, level=level)
log_handler = LogHandler()
- async with Client(mcp, log_handler=log_handler.handle_log) as client:
+ async with Client(
+ mcp, mode="legacy", log_handler=log_handler.handle_log
+ ) as client:
await client.set_logging_level("warning")
await client.call_tool("echo_log", {"message": "info msg", "level": "info"})
await client.call_tool(
diff --git a/tests/client/test_openapi.py b/tests/client/test_openapi.py
index 236e6efb3..eaaecb8a8 100644
--- a/tests/client/test_openapi.py
+++ b/tests/client/test_openapi.py
@@ -196,7 +196,11 @@ async def test_client_headers_proxy(proxy_server: str):
"""
Test that client headers are passed through the proxy to the remove server.
"""
- async with Client(transport=StreamableHttpTransport(proxy_server)) as client:
+ # The proxy backend forwards over the legacy handshake, so align the outer
+ # client's era with it.
+ async with Client(
+ transport=StreamableHttpTransport(proxy_server), mode="legacy"
+ ) as client:
result = await client.read_resource("resource://get_headers_headers_get")
assert isinstance(result[0], TextResourceContents)
headers = json.loads(result[0].text)
diff --git a/tests/client/test_roots.py b/tests/client/test_roots.py
index d3bc7d5ca..e71f0c849 100644
--- a/tests/client/test_roots.py
+++ b/tests/client/test_roots.py
@@ -36,7 +36,8 @@ class TestClientRoots:
@pytest.mark.parametrize("roots", [["file://x/y/z", "file://x/y/z"]])
async def test_valid_roots(self, fastmcp_server: FastMCP, roots: list[str]):
- async with Client(fastmcp_server, roots=roots) as client:
+ # ctx.list_roots is a legacy-era server-initiated feature.
+ async with Client(fastmcp_server, mode="legacy", roots=roots) as client:
result = await client.call_tool("list_roots", {})
assert result.data == [
"file://x/y/z",
diff --git a/tests/client/test_sampling.py b/tests/client/test_sampling.py
index 2fc75e311..98ec74494 100644
--- a/tests/client/test_sampling.py
+++ b/tests/client/test_sampling.py
@@ -76,7 +76,9 @@ async def test_simple_sampling(fastmcp_server: FastMCP):
) -> str:
return "This is the sample message!"
- async with Client(fastmcp_server, sampling_handler=sampling_handler) as client:
+ async with Client(
+ fastmcp_server, mode="legacy", sampling_handler=sampling_handler
+ ) as client:
result = await client.call_tool("simple_sample", {"message": "Hello, world!"})
assert result.data == "This is the sample message!"
@@ -88,7 +90,9 @@ async def test_sampling_with_system_prompt(fastmcp_server: FastMCP):
assert params.system_prompt is not None
return params.system_prompt
- async with Client(fastmcp_server, sampling_handler=sampling_handler) as client:
+ async with Client(
+ fastmcp_server, mode="legacy", sampling_handler=sampling_handler
+ ) as client:
result = await client.call_tool(
"sample_with_system_prompt", {"message": "Hello, world!"}
)
@@ -110,7 +114,9 @@ async def test_sampling_with_messages(fastmcp_server: FastMCP):
assert messages[1].content.text == "How can I assist you today?"
return "I need to think."
- async with Client(fastmcp_server, sampling_handler=sampling_handler) as client:
+ async with Client(
+ fastmcp_server, mode="legacy", sampling_handler=sampling_handler
+ ) as client:
result = await client.call_tool(
"sample_with_messages", {"message": "Hello, world!"}
)
@@ -144,7 +150,9 @@ async def test_sampling_with_image(fastmcp_server: FastMCP):
assert len(messages) == 2
return to_json(messages).decode()
- async with Client(fastmcp_server, sampling_handler=sampling_handler) as client:
+ async with Client(
+ fastmcp_server, mode="legacy", sampling_handler=sampling_handler
+ ) as client:
image_bytes = b"abc123"
result = await client.call_tool(
"sample_with_image", {"image_bytes": image_bytes}
@@ -264,6 +272,7 @@ class TestSamplingWithTools:
# Explicitly disable tools capability by passing SamplingCapability without tools
async with Client(
server,
+ mode="legacy",
sampling_handler=sampling_handler,
sampling_capabilities=mcp_types.SamplingCapability(), # No tools
) as client:
diff --git a/tests/client/test_streamable_http.py b/tests/client/test_streamable_http.py
index f109587e6..81f8371b2 100644
--- a/tests/client/test_streamable_http.py
+++ b/tests/client/test_streamable_http.py
@@ -150,7 +150,7 @@ async def nested_server():
async def test_ping(streamable_http_server: str):
"""Test pinging the server."""
async with Client(
- transport=StreamableHttpTransport(streamable_http_server)
+ transport=StreamableHttpTransport(streamable_http_server), mode="legacy"
) as client:
result = await client.ping()
assert result is True
@@ -163,7 +163,8 @@ async def test_ping_with_streamable_http_alias(
async with Client(
transport=StreamableHttpTransport(
streamable_http_server_with_streamable_http_alias
- )
+ ),
+ mode="legacy",
) as client:
result = await client.ping()
assert result is True
@@ -187,7 +188,7 @@ async def test_session_id_callback(streamable_http_server: str):
"""Test getting mcp-session-id from the transport."""
transport = StreamableHttpTransport(streamable_http_server)
assert transport.get_session_id() is None
- async with Client(transport=transport):
+ async with Client(transport=transport, mode="legacy"):
session_id = transport.get_session_id()
assert session_id is not None
@@ -226,6 +227,7 @@ async def test_elicitation_tool(streamable_http_server: str, request):
async with Client(
transport=StreamableHttpTransport(streamable_http_server),
elicitation_handler=elicitation_handler,
+ mode="legacy",
) as client:
result = await client.call_tool("elicit")
assert result.data == "You said your name was: Alice!"
@@ -253,7 +255,9 @@ async def test_stateless_http_still_accepts_post(streamable_http_server: str):
async def test_nested_streamable_http_server_resolves_correctly(nested_server: str):
"""Test patch for https://github.com/modelcontextprotocol/python-sdk/pull/659"""
- async with Client(transport=StreamableHttpTransport(nested_server)) as client:
+ async with Client(
+ transport=StreamableHttpTransport(nested_server), mode="legacy"
+ ) as client:
result = await client.ping()
assert result is True
@@ -265,11 +269,14 @@ async def test_nested_streamable_http_server_resolves_correctly(nested_server: s
class TestTimeout:
async def test_timeout(self, streamable_http_server: str):
# note this transport behaves differently than others and raises
- # MCPError from the *client* context
+ # MCPError from the *client* context. Pinned to legacy: on a modern
+ # (server/discover) connection a connect-time timeout surfaces as a raw
+ # httpx.ReadTimeout from the probe rather than a wrapped MCPError.
with pytest.raises(MCPError, match="timed out"):
async with Client(
transport=StreamableHttpTransport(streamable_http_server),
timeout=0.02,
+ mode="legacy",
) as client:
await client.call_tool("sleep", {"seconds": 0.05})
diff --git a/tests/client/transports/test_memory_transport.py b/tests/client/transports/test_memory_transport.py
index e5eab41cd..a67784c89 100644
--- a/tests/client/transports/test_memory_transport.py
+++ b/tests/client/transports/test_memory_transport.py
@@ -47,7 +47,7 @@ async def test_task_teardown_does_not_hang():
t0 = time.monotonic()
- async with Client(mcp) as client:
+ async with Client(mcp, mode="legacy") as client:
task = await client.call_tool("fast_tool", {"x": 21}, task=True)
result = await task.result()
assert result.data == 42
diff --git a/tests/deprecated/test_elicitation.py b/tests/deprecated/test_elicitation.py
index d86e549d8..cbb35541c 100644
--- a/tests/deprecated/test_elicitation.py
+++ b/tests/deprecated/test_elicitation.py
@@ -25,5 +25,7 @@ async def test_elicitation_none_response_type_warns_deprecation():
async def elicitation_handler(message, response_type, params, ctx):
return ElicitResult(action="accept", content={})
- async with Client(mcp, elicitation_handler=elicitation_handler) as client:
+ async with Client(
+ mcp, mode="legacy", elicitation_handler=elicitation_handler
+ ) as client:
await client.call_tool("my_tool", {})
diff --git a/tests/server/auth/test_authorization.py b/tests/server/auth/test_authorization.py
index 5d18705b1..844c3bdb5 100644
--- a/tests/server/auth/test_authorization.py
+++ b/tests/server/auth/test_authorization.py
@@ -825,6 +825,11 @@ class TestAuthMiddlewareCallTool:
class TestAuthMiddlewareVersionedRequests:
+ # The resource/template/prompt denial cases are pinned to legacy: the
+ # authorization error message is surfaced to the client on the handshake
+ # era, but the modern server runner masks the raised denial as a generic
+ # "Internal server error". The tool case surfaces via an isError result and
+ # stays era-neutral.
async def test_middleware_blocks_explicit_restricted_tool_version(self):
"""AuthMiddleware should check the requested tool version."""
mcp = make_restricted_tag_server()
@@ -878,7 +883,7 @@ class TestAuthMiddlewareVersionedRequests:
tok = set_token(make_token(scopes=["read"]))
try:
- async with Client(mcp) as client:
+ async with Client(mcp, mode="legacy") as client:
with pytest.raises(Exception, match="authorization|insufficient"):
await client.read_resource("data://info", version="1.0")
finally:
@@ -898,7 +903,7 @@ class TestAuthMiddlewareVersionedRequests:
tok = set_token(make_token(scopes=["read"]))
try:
- async with Client(mcp) as client:
+ async with Client(mcp, mode="legacy") as client:
with pytest.raises(Exception, match="authorization|insufficient"):
await client.read_resource("data://items/123", version="1.0")
finally:
@@ -918,7 +923,7 @@ class TestAuthMiddlewareVersionedRequests:
tok = set_token(make_token(scopes=["read"]))
try:
- async with Client(mcp) as client:
+ async with Client(mcp, mode="legacy") as client:
with pytest.raises(Exception, match="authorization|insufficient"):
await client.get_prompt("greet", version="1.0")
finally:
@@ -942,6 +947,12 @@ class TestComponentAuthDenialMessage:
The message must stay ambiguous ("not found or not authorized") rather than
asserting the component does not exist (misleading) or that it exists but is
forbidden (leaks existence to unauthorized callers).
+
+ The resource/prompt cases are pinned to legacy: their denial message is
+ surfaced only on the handshake era, where the read/get path converts the
+ error to a client-visible message; the modern server runner masks it as a
+ generic "Internal server error". The tool case surfaces via an isError
+ result and stays era-neutral.
"""
async def test_call_tool_denied_by_component_auth(self):
@@ -972,7 +983,7 @@ class TestComponentAuthDenialMessage:
token = make_token(scopes=["read"])
tok = set_token(token)
try:
- async with Client(mcp) as client:
+ async with Client(mcp, mode="legacy") as client:
with pytest.raises(Exception) as exc_info:
await client.read_resource("data://secret")
message = str(exc_info.value)
@@ -990,7 +1001,7 @@ class TestComponentAuthDenialMessage:
token = make_token(scopes=["read"])
tok = set_token(token)
try:
- async with Client(mcp) as client:
+ async with Client(mcp, mode="legacy") as client:
with pytest.raises(Exception) as exc_info:
await client.get_prompt("secret_prompt")
message = str(exc_info.value)
diff --git a/tests/server/middleware/test_caching.py b/tests/server/middleware/test_caching.py
index c5e3f385a..45201c640 100644
--- a/tests/server/middleware/test_caching.py
+++ b/tests/server/middleware/test_caching.py
@@ -381,7 +381,9 @@ class TestResponseCachingMiddlewareIntegration:
assert not hasattr(cached_resources[0], "fn")
assert not hasattr(cached_prompts[0], "fn")
- async with Client(mcp) as client:
+ # Pinned to legacy: the tool's `execution.task_support` (SEP-1686) is
+ # advertised in the handshake-era tool listing; the modern listing omits it.
+ async with Client(mcp, mode="legacy") as client:
for _ in range(2):
tools = await client.list_tools()
resources = await client.list_resources()
diff --git a/tests/server/middleware/test_initialization_middleware.py b/tests/server/middleware/test_initialization_middleware.py
index 3a077b367..a05727d04 100644
--- a/tests/server/middleware/test_initialization_middleware.py
+++ b/tests/server/middleware/test_initialization_middleware.py
@@ -123,7 +123,7 @@ async def test_simple_initialization_hook():
server.add_middleware(middleware)
# Connect client
- async with Client(server):
+ async with Client(server, mode="legacy"):
# Middleware should have been called
assert middleware.called is True, "on_initialize was not called"
@@ -139,7 +139,7 @@ async def test_middleware_receives_initialization():
return f"Result: {x}"
# Connect client
- async with Client(server) as client:
+ async with Client(server, mode="legacy") as client:
# Middleware should have been called during initialization
assert middleware.initialized is True
@@ -160,7 +160,7 @@ async def test_client_detection_middleware():
return "example"
# Connect with a client
- async with Client(server) as client:
+ async with Client(server, mode="legacy") as client:
# Middleware should have been called during initialization
assert middleware.initialization_called is True
assert middleware.is_test_client is True
@@ -190,7 +190,7 @@ async def test_multiple_middleware_initialization():
def test_tool() -> str:
return "test"
- async with Client(server) as client:
+ async with Client(server, mode="legacy") as client:
# Both middleware should have processed initialization
assert init_mw.initialized is True
assert detect_mw.initialization_called is True
@@ -241,7 +241,7 @@ async def test_session_state_persists_across_tool_calls():
def test_tool() -> str:
return "success"
- async with Client(server) as client:
+ async with Client(server, mode="legacy") as client:
# First call - state should be None initially
result = await client.call_tool("test_tool", {})
assert isinstance(result.content[0], TextContent)
@@ -287,7 +287,7 @@ async def test_middleware_can_access_initialize_result():
middleware = ResponseCapturingMiddleware()
server.add_middleware(middleware)
- async with Client(server):
+ async with Client(server, mode="legacy"):
# Middleware should have captured the InitializeResult
assert middleware.initialize_result is not None
assert isinstance(middleware.initialize_result, mt.InitializeResult)
@@ -315,7 +315,7 @@ async def test_middleware_mcp_error_during_initialization():
server.add_middleware(ErrorThrowingMiddleware())
with pytest.raises(MCPError) as exc_info:
- async with Client(server):
+ async with Client(server, mode="legacy"):
pass
assert exc_info.value.error.message == "Invalid initialization parameters"
@@ -337,7 +337,7 @@ async def test_middleware_mcp_error_before_call_next():
server.add_middleware(EarlyErrorMiddleware())
with pytest.raises(MCPError) as exc_info:
- async with Client(server):
+ async with Client(server, mode="legacy"):
pass
assert exc_info.value.error.message == "Request validation failed"
@@ -370,7 +370,7 @@ async def test_middleware_mcp_error_after_call_next():
server.add_middleware(middleware)
# Error is logged but not re-raised to prevent duplicate response
- async with Client(server):
+ async with Client(server, mode="legacy"):
pass
assert middleware.error_raised is True
@@ -403,7 +403,7 @@ async def test_state_isolation_between_streamable_http_clients():
# Client 1 stores its value
transport1 = StreamableHttpTransport(url=url)
- async with Client(transport=transport1) as client1:
+ async with Client(transport=transport1, mode="legacy") as client1:
result1 = await client1.call_tool(
"store_and_read", {"value": "client1-value"}
)
@@ -414,7 +414,7 @@ async def test_state_isolation_between_streamable_http_clients():
# Client 2 should have completely isolated state
transport2 = StreamableHttpTransport(url=url)
- async with Client(transport=transport2) as client2:
+ async with Client(transport=transport2, mode="legacy") as client2:
result2 = await client2.call_tool(
"store_and_read", {"value": "client2-value"}
)
diff --git a/tests/server/middleware/test_message_visibility.py b/tests/server/middleware/test_message_visibility.py
index d70aa7468..63dcac8f5 100644
--- a/tests/server/middleware/test_message_visibility.py
+++ b/tests/server/middleware/test_message_visibility.py
@@ -116,7 +116,7 @@ class TestUnroutableAndMalformed:
recorder = HookRecorder()
server.add_middleware(recorder)
- async with Client(server) as client:
+ async with Client(server, mode="legacy") as client:
with pytest.raises(MCPError):
await client.session._dispatcher.send_raw_request(
"does/not/exist", {}, {}
@@ -133,7 +133,7 @@ class TestUnroutableAndMalformed:
recorder = HookRecorder()
server.add_middleware(recorder)
- async with Client(server) as client:
+ async with Client(server, mode="legacy") as client:
with pytest.raises(MCPError):
await client.session._dispatcher.send_raw_request(
"tools/call", {"not_a_valid": "param"}, {}
@@ -216,7 +216,7 @@ class TestMessageModification:
server = _adder()
server.add_middleware(RewriteLevel())
- async with Client(server) as client:
+ async with Client(server, mode="legacy") as client:
await client.session._dispatcher.send_raw_request(
"logging/setLevel", {"level": "not-a-valid-level"}, {}
)
@@ -227,7 +227,7 @@ class TestMessageModification:
recorder = HookRecorder()
server.add_middleware(recorder)
- async with Client(server) as client:
+ async with Client(server, mode="legacy") as client:
await client.session._dispatcher.send_raw_request(
"logging/setLevel", {"level": "debug"}, {}
)
@@ -254,7 +254,7 @@ class TestMessageModification:
server.add_middleware(recorder)
server.add_middleware(RewriteMethod())
- async with Client(server) as client:
+ async with Client(server, mode="legacy") as client:
await client.session._dispatcher.send_raw_request("ping", {}, {})
# Had the rewrite redirected dispatch, the component handler would have
@@ -284,7 +284,7 @@ class TestMessageModification:
server.add_middleware(RepairAttempt())
server.add_middleware(recorder)
- async with Client(server) as client:
+ async with Client(server, mode="legacy") as client:
with pytest.raises(MCPError):
await client.session._dispatcher.send_raw_request(
"tools/call", {"not_a_valid": "param"}, {}
diff --git a/tests/server/middleware/test_middleware.py b/tests/server/middleware/test_middleware.py
index f58736437..8518845b7 100644
--- a/tests/server/middleware/test_middleware.py
+++ b/tests/server/middleware/test_middleware.py
@@ -173,7 +173,7 @@ class TestMiddlewareHooks:
async def test_call_tool(
self, mcp_server: FastMCP, recording_middleware: RecordingMiddleware
):
- async with Client(mcp_server) as client:
+ async with Client(mcp_server, mode="legacy") as client:
await client.call_tool("add", {"a": 1, "b": 2})
assert recording_middleware.assert_called(at_least=9)
@@ -299,7 +299,7 @@ class TestMiddlewareHooks:
async def test_initialize(
self, mcp_server: FastMCP, recording_middleware: RecordingMiddleware
):
- async with Client(mcp_server) as client:
+ async with Client(mcp_server, mode="legacy") as client:
await client.ping()
assert recording_middleware.assert_called(at_least=1)
diff --git a/tests/server/middleware/test_middleware_nested.py b/tests/server/middleware/test_middleware_nested.py
index cc2eea6c0..a67b78830 100644
--- a/tests/server/middleware/test_middleware_nested.py
+++ b/tests/server/middleware/test_middleware_nested.py
@@ -477,7 +477,7 @@ class TestProxyServer:
# proxy server will have its tools listed as well as called in order to
# apply transforms and filters prior to the call.
proxy_server = create_proxy(mcp_server, name="Proxy Server")
- async with Client(proxy_server) as client:
+ async with Client(proxy_server, mode="legacy") as client:
await client.call_tool("add", {"a": 1, "b": 2})
assert recording_middleware.assert_called(at_least=6)
diff --git a/tests/server/middleware/test_ping.py b/tests/server/middleware/test_ping.py
index b35616fc1..4713ca7a5 100644
--- a/tests/server/middleware/test_ping.py
+++ b/tests/server/middleware/test_ping.py
@@ -193,7 +193,7 @@ class TestPingMiddlewareIntegration:
assert len(middleware._active_sessions) == 0
- async with Client(mcp) as client:
+ async with Client(mcp, mode="legacy") as client:
result = await client.call_tool("hello")
assert result.content[0].text == "Hello!"
@@ -214,7 +214,7 @@ class TestPingMiddlewareIntegration:
def hello() -> str:
return "Hello!"
- async with Client(mcp) as client:
+ async with Client(mcp, mode="legacy") as client:
await client.call_tool("hello")
# Should have one active session
assert len(middleware._active_sessions) == 1
diff --git a/tests/server/middleware/test_tool_injection.py b/tests/server/middleware/test_tool_injection.py
index 983736412..dcbccf5a8 100644
--- a/tests/server/middleware/test_tool_injection.py
+++ b/tests/server/middleware/test_tool_injection.py
@@ -121,7 +121,10 @@ class TestToolInjectionMiddleware:
)
base_server.add_middleware(middleware)
- async with Client[FastMCPTransport](base_server) as client:
+ # Pinned to legacy: a middleware-injected tool's raised exception is
+ # surfaced with its message on the handshake era; the modern server
+ # runner reports it as a generic "Internal server error".
+ async with Client[FastMCPTransport](base_server, mode="legacy") as client:
with pytest.raises(Exception, match="Cannot divide by zero"):
_ = await client.call_tool(name="divide", arguments={"a": 10, "b": 0})
diff --git a/tests/server/providers/proxy/test_proxy_client.py b/tests/server/providers/proxy/test_proxy_client.py
index 216d283e8..e99190c0e 100644
--- a/tests/server/providers/proxy/test_proxy_client.py
+++ b/tests/server/providers/proxy/test_proxy_client.py
@@ -1,4 +1,5 @@
from dataclasses import dataclass
+from typing import cast
import pytest
from anyio import create_task_group
@@ -21,6 +22,42 @@ from fastmcp.server.elicitation import AcceptedElicitation
from fastmcp.server.providers.proxy import ProxyClient, _create_client_factory
+class TestProxyClientEraDefault:
+ """`ProxyClient` pins the handshake era independently of `Client`'s default.
+
+ `fastmcp.Client` defaults to `mode="auto"` (negotiate the newest mutual era),
+ but a proxy backend forwards the initialize handshake and server-initiated
+ push features (sampling / elicitation / roots / logging), which live only on
+ the handshake era. So `ProxyClient` must default to `"legacy"` regardless of
+ what `Client` defaults to — flipping the general client default must never
+ change proxy behavior.
+ """
+
+ def test_client_default_is_auto(self):
+ mcp = FastMCP("Backend")
+ assert Client(mcp).mode == "auto"
+
+ def test_proxy_client_defaults_to_legacy(self):
+ mcp = FastMCP("Backend")
+ assert ProxyClient(mcp).mode == "legacy"
+
+ def test_proxy_client_can_opt_into_auto(self):
+ """The legacy default is an override-able floor, not a hard pin."""
+ mcp = FastMCP("Backend")
+ assert ProxyClient(mcp, mode="auto").mode == "auto"
+
+ def test_create_proxy_backend_defaults_to_legacy(self):
+ """The backend client `create_proxy` builds is legacy by default too."""
+ mcp = FastMCP("Backend")
+ factory = _create_client_factory(mcp)
+ assert cast(Client, factory()).mode == "legacy"
+
+ def test_create_proxy_backend_honors_explicit_mode(self):
+ mcp = FastMCP("Backend")
+ factory = _create_client_factory(mcp, mode="auto")
+ assert cast(Client, factory()).mode == "auto"
+
+
@pytest.fixture
def fastmcp_server():
mcp = FastMCP("TestServer")
@@ -97,7 +134,7 @@ class TestProxyClient:
"""
Test that the proxy client correctly forwards the `echo` tool meta.
"""
- async with Client(proxy_server) as client:
+ async with Client(proxy_server, mode="legacy") as client:
tools = await client.list_tools()
echo_tool = next(t for t in tools if t.name == "echo")
assert echo_tool.meta == {"fastmcp": {"tags": ["echo"]}}
@@ -106,7 +143,7 @@ class TestProxyClient:
"""
Test that the proxy client correctly forwards an error response.
"""
- async with Client(proxy_server) as client:
+ async with Client(proxy_server, mode="legacy") as client:
with pytest.raises(ToolError, match="Elicitation not supported"):
await client.call_tool("elicit", {})
@@ -121,7 +158,7 @@ class TestProxyClient:
roots_handler_called = True
return []
- async with Client(proxy_server, roots=roots_handler) as client:
+ async with Client(proxy_server, mode="legacy", roots=roots_handler) as client:
await client.call_tool("list_roots", {})
assert roots_handler_called
@@ -130,7 +167,9 @@ class TestProxyClient:
"""
Test that the proxy client correctly forwards the `list_roots` response.
"""
- async with Client(proxy_server, roots=["file://x/y/z"]) as client:
+ async with Client(
+ proxy_server, mode="legacy", roots=["file://x/y/z"]
+ ) as client:
result = await client.call_tool("list_roots", {})
assert result.data == ["file://x/y/z"]
@@ -161,7 +200,9 @@ class TestProxyClient:
)
return ""
- async with Client(proxy_server, sampling_handler=sampling_handler) as client:
+ async with Client(
+ proxy_server, mode="legacy", sampling_handler=sampling_handler
+ ) as client:
await client.call_tool("sampling", {})
assert sampling_handler_called
@@ -171,7 +212,7 @@ class TestProxyClient:
Test that the proxy client correctly forwards the `sampling` response.
"""
async with Client(
- proxy_server, sampling_handler=lambda *args: "I love FastMCP"
+ proxy_server, mode="legacy", sampling_handler=lambda *args: "I love FastMCP"
) as client:
result = await client.call_tool("sampling", {})
assert result.data == "I love FastMCP"
@@ -199,7 +240,7 @@ class TestProxyClient:
return ElicitResult(action="accept", content=response_type(name="Alice"))
async with Client(
- proxy_server, elicitation_handler=elicitation_handler
+ proxy_server, mode="legacy", elicitation_handler=elicitation_handler
) as client:
await client.call_tool("elicit", {})
@@ -217,6 +258,7 @@ class TestProxyClient:
async with Client(
proxy_server,
+ mode="legacy",
elicitation_handler=elicitation_handler,
) as client:
result = await client.call_tool("elicit", {})
@@ -233,7 +275,7 @@ class TestProxyClient:
return ElicitResult(action="decline")
async with Client(
- proxy_server, elicitation_handler=elicitation_handler
+ proxy_server, mode="legacy", elicitation_handler=elicitation_handler
) as client:
result = await client.call_tool("elicit", {})
assert result.data == "No name provided."
@@ -251,7 +293,9 @@ class TestProxyClient:
assert message.level == "info"
assert message.logger == "test"
- async with Client(proxy_server, log_handler=log_handler) as client:
+ async with Client(
+ proxy_server, mode="legacy", log_handler=log_handler
+ ) as client:
await client.call_tool(
"log", {"message": "Hello, world!", "level": "info", "logger": "test"}
)
@@ -277,7 +321,9 @@ class TestProxyClient:
dict(progress=progress, total=total, message=message)
)
- async with Client(proxy_server, progress_handler=progress_handler) as client:
+ async with Client(
+ proxy_server, mode="legacy", progress_handler=progress_handler
+ ) as client:
await client.call_tool("report_progress", {})
assert PROGRESS_MESSAGES == EXPECTED_PROGRESS_MESSAGES
@@ -293,8 +339,8 @@ class TestProxyClient:
results["logger_b"] = message
async with (
- Client(proxy_server, log_handler=log_handler_a) as client_a,
- Client(proxy_server, log_handler=log_handler_b) as client_b,
+ Client(proxy_server, mode="legacy", log_handler=log_handler_a) as client_a,
+ Client(proxy_server, mode="legacy", log_handler=log_handler_b) as client_b,
):
async with create_task_group() as tg:
tg.start_soon(
@@ -336,8 +382,12 @@ class TestProxyClient:
results[name] = result.data
async with (
- Client(proxy_server, elicitation_handler=elicitation_handler_a) as client_a,
- Client(proxy_server, elicitation_handler=elicitation_handler_b) as client_b,
+ Client(
+ proxy_server, mode="legacy", elicitation_handler=elicitation_handler_a
+ ) as client_a,
+ Client(
+ proxy_server, mode="legacy", elicitation_handler=elicitation_handler_b
+ ) as client_b,
):
async with create_task_group() as tg:
tg.start_soon(
@@ -396,7 +446,7 @@ class TestProxyClient:
return {"content": "Test content", "acknowledge": True}
async with Client(
- proxy_server, elicitation_handler=elicitation_handler
+ proxy_server, mode="legacy", elicitation_handler=elicitation_handler
) as client:
result = await client.call_tool("elicit_with_defaults", {})
assert result.data == "Content: Test content, Acknowledge: True"
@@ -406,7 +456,7 @@ class TestProxyClient:
from fastmcp.server.providers.proxy import FastMCPProxy
# Create a disconnected client (should use fresh sessions per request)
- base_client = Client(fastmcp_server)
+ base_client = Client(fastmcp_server, mode="legacy")
# Test both create_proxy convenience function and direct client_factory usage
proxy_via_create_proxy = create_proxy(base_client)
@@ -488,7 +538,9 @@ class TestProxyServerInitiatedForwardingNonTool:
roots_handler_called = True
return ["file://from/client"]
- async with Client(roots_proxy_server, roots=roots_handler) as client:
+ async with Client(
+ roots_proxy_server, mode="legacy", roots=roots_handler
+ ) as client:
result = await client.read_resource("data://roots")
assert roots_handler_called
@@ -504,7 +556,9 @@ class TestProxyServerInitiatedForwardingNonTool:
roots_handler_called = True
return ["file://from/client"]
- async with Client(roots_proxy_server, roots=roots_handler) as client:
+ async with Client(
+ roots_proxy_server, mode="legacy", roots=roots_handler
+ ) as client:
result = await client.read_resource("data://roots/abc")
assert roots_handler_called
@@ -520,7 +574,9 @@ class TestProxyServerInitiatedForwardingNonTool:
roots_handler_called = True
return ["file://from/client"]
- async with Client(roots_proxy_server, roots=roots_handler) as client:
+ async with Client(
+ roots_proxy_server, mode="legacy", roots=roots_handler
+ ) as client:
result = await client.get_prompt("roots_prompt")
assert roots_handler_called
diff --git a/tests/server/providers/proxy/test_proxy_server.py b/tests/server/providers/proxy/test_proxy_server.py
index 2d73ae9c9..7b3895095 100644
--- a/tests/server/providers/proxy/test_proxy_server.py
+++ b/tests/server/providers/proxy/test_proxy_server.py
@@ -180,7 +180,7 @@ async def test_create_proxy_with_client(fastmcp_server):
async def test_create_proxy_with_server(fastmcp_server):
"""create_proxy should accept a FastMCP instance."""
proxy = create_proxy(fastmcp_server)
- async with Client(proxy) as client:
+ async with Client(proxy, mode="legacy") as client:
result = await client.call_tool("greet", {"name": "Test"})
assert result.data == "Hello, Test!"
@@ -188,7 +188,7 @@ async def test_create_proxy_with_server(fastmcp_server):
async def test_create_proxy_with_transport(fastmcp_server):
"""create_proxy should accept a ClientTransport."""
proxy = create_proxy(FastMCPTransport(fastmcp_server))
- async with Client(proxy) as client:
+ async with Client(proxy, mode="legacy") as client:
result = await client.call_tool("greet", {"name": "Test"})
assert result.data == "Hello, Test!"
@@ -198,7 +198,7 @@ async def test_proxy_forwards_upstream_instructions():
upstream = FastMCP(name="upstream", instructions="USE_THIS_MARKER_123")
proxy = create_proxy(upstream, name="proxy")
- async with Client(proxy) as client:
+ async with Client(proxy, mode="legacy") as client:
assert client.initialize_result is not None
assert client.initialize_result.instructions == "USE_THIS_MARKER_123"
@@ -208,7 +208,7 @@ async def test_proxy_own_instructions_take_precedence():
upstream = FastMCP(name="upstream", instructions="upstream instructions")
proxy = create_proxy(upstream, name="proxy", instructions="proxy instructions")
- async with Client(proxy) as client:
+ async with Client(proxy, mode="legacy") as client:
assert client.initialize_result is not None
assert client.initialize_result.instructions == "proxy instructions"
@@ -218,7 +218,7 @@ async def test_proxy_instructions_none_when_upstream_has_none():
upstream = FastMCP(name="upstream")
proxy = create_proxy(upstream, name="proxy")
- async with Client(proxy) as client:
+ async with Client(proxy, mode="legacy") as client:
assert client.initialize_result is not None
assert client.initialize_result.instructions is None
@@ -252,7 +252,7 @@ async def test_proxy_with_async_client_factory():
async def test_proxy_ping_forwards_to_remote_server(fastmcp_server):
proxy = create_proxy(fastmcp_server)
- async with Client(proxy) as client:
+ async with Client(proxy, mode="legacy") as client:
assert await client.ping() is True
@@ -264,7 +264,7 @@ async def test_proxy_ping_surfaces_wrong_remote_path():
# SDK v2 surfaces a wrong remote path as an HTTP "Not Found" rather than
# the v1 "Session terminated" message.
with pytest.raises(MCPError, match="Not Found"):
- async with Client(proxy):
+ async with Client(proxy, mode="legacy"):
pass
@@ -276,7 +276,7 @@ async def test_proxy_initialize_forwards_remote_connection_error():
)
with pytest.raises(MCPError, match="Client failed to connect"):
- async with Client(proxy):
+ async with Client(proxy, mode="legacy"):
pass
@@ -299,7 +299,7 @@ async def test_proxy_list_tools_client_surfaces_remote_connection_error():
)
with pytest.raises(MCPError, match="Client failed to connect"):
- async with Client(proxy) as client:
+ async with Client(proxy, mode="legacy") as client:
await client.list_tools()
@@ -356,7 +356,7 @@ class TestTools:
)
proxy = create_proxy(server)
- async with Client(proxy) as client:
+ async with Client(proxy, mode="legacy") as client:
result = await client.call_tool("add_transformed", {"a": 1, "b": 2})
assert result.data == 3
@@ -366,31 +366,31 @@ class TestTools:
assert tool.description is None
async def test_list_tools_same_as_original(self, fastmcp_server, proxy_server):
- async with Client(fastmcp_server) as original_client:
+ async with Client(fastmcp_server, mode="legacy") as original_client:
original = await original_client.list_tools()
- async with Client(proxy_server) as proxy_client:
+ async with Client(proxy_server, mode="legacy") as proxy_client:
proxied = await proxy_client.list_tools()
assert proxied == original
async def test_call_tool_result_same_as_original(
self, fastmcp_server: FastMCP, proxy_server: FastMCPProxy
):
- async with Client(fastmcp_server) as original_client:
+ async with Client(fastmcp_server, mode="legacy") as original_client:
result = await original_client.call_tool("greet", {"name": "Alice"})
- async with Client(proxy_server) as proxy_client:
+ async with Client(proxy_server, mode="legacy") as proxy_client:
proxy_result = await proxy_client.call_tool("greet", {"name": "Alice"})
assert result.content == proxy_result.content
assert result.data == proxy_result.data
async def test_call_tool_calls_tool(self, proxy_server):
- async with Client(proxy_server) as client:
+ async with Client(proxy_server, mode="legacy") as client:
proxy_result = await client.call_tool("add", {"a": 1, "b": 2})
assert proxy_result.data == 3
async def test_error_tool_raises_error(self, proxy_server):
with pytest.raises(ToolError, match="This is a test error"):
- async with Client(proxy_server) as client:
+ async with Client(proxy_server, mode="legacy") as client:
await client.call_tool("error_tool", {})
async def test_error_tool_with_image_content(self, proxy_server):
@@ -407,7 +407,7 @@ class TestTools:
Client, "call_tool_mcp", new_callable=AsyncMock, return_value=error_result
):
with pytest.raises(ToolError):
- async with Client(proxy_server) as client:
+ async with Client(proxy_server, mode="legacy") as client:
await client.call_tool("error_tool", {})
async def test_error_tool_with_empty_content(self, proxy_server):
@@ -420,7 +420,7 @@ class TestTools:
Client, "call_tool_mcp", new_callable=AsyncMock, return_value=error_result
):
with pytest.raises(ToolError):
- async with Client(proxy_server) as client:
+ async with Client(proxy_server, mode="legacy") as client:
await client.call_tool("error_tool", {})
async def test_error_tool_passthrough_preserves_content(self, proxy_server):
@@ -437,7 +437,7 @@ class TestTools:
with patch.object(
Client, "call_tool_mcp", new_callable=AsyncMock, return_value=error_result
):
- async with Client(proxy_server) as client:
+ async with Client(proxy_server, mode="legacy") as client:
result = await client.call_tool("error_tool", {}, raise_on_error=False)
assert result.is_error is True
@@ -456,7 +456,7 @@ class TestTools:
meta={"custom_key": "custom_value", "processed": True},
)
- async with Client(proxy_server) as client:
+ async with Client(proxy_server, mode="legacy") as client:
result = await client.call_tool("tool_with_meta", {"value": "test"})
assert isinstance(result.content[0], TextContent)
@@ -472,7 +472,7 @@ class TestTools:
def greet(name: str, extra: str = "extra") -> str:
return f"Overwritten, {name}! {extra}"
- async with Client(proxy_server) as client:
+ async with Client(proxy_server, mode="legacy") as client:
result = await client.call_tool("greet", {"name": "Marvin", "extra": "abc"})
assert result.data == "Overwritten, Marvin! abc"
@@ -485,7 +485,7 @@ class TestTools:
def greet(name: str, extra: str = "extra") -> str:
return f"Overwritten, {name}! {extra}"
- async with Client(proxy_server) as client:
+ async with Client(proxy_server, mode="legacy") as client:
tools = await client.list_tools()
greet_tool = next(t for t in tools if t.name == "greet")
assert "extra" in greet_tool.input_schema["properties"]
@@ -508,27 +508,27 @@ class TestResources:
assert wave_resource.icons == [Icon(src="https://example.com/wave-icon.png")]
async def test_list_resources_same_as_original(self, fastmcp_server, proxy_server):
- async with Client(fastmcp_server) as original_client:
+ async with Client(fastmcp_server, mode="legacy") as original_client:
original = await original_client.list_resources()
- async with Client(proxy_server) as proxy_client:
+ async with Client(proxy_server, mode="legacy") as proxy_client:
proxied = await proxy_client.list_resources()
assert proxied == original
async def test_read_resource(self, proxy_server: FastMCPProxy):
- async with Client(proxy_server) as client:
+ async with Client(proxy_server, mode="legacy") as client:
result = await client.read_resource("resource://wave")
assert isinstance(result[0], TextResourceContents)
assert result[0].text == "👋"
async def test_read_resource_same_as_original(self, fastmcp_server, proxy_server):
- async with Client(fastmcp_server) as client:
+ async with Client(fastmcp_server, mode="legacy") as client:
result = await client.read_resource("resource://wave")
- async with Client(proxy_server) as client:
+ async with Client(proxy_server, mode="legacy") as client:
proxy_result = await client.read_resource("resource://wave")
assert proxy_result == result
async def test_read_json_resource(self, proxy_server: FastMCPProxy):
- async with Client(proxy_server) as client:
+ async with Client(proxy_server, mode="legacy") as client:
result = await client.read_resource("data://users")
assert len(result) == 1
assert isinstance(result[0], TextResourceContents)
@@ -541,11 +541,11 @@ class TestResources:
):
"""Test that proxy correctly returns all resource contents, not just the first one."""
# Read from original server
- async with Client(fastmcp_server) as client:
+ async with Client(fastmcp_server, mode="legacy") as client:
original_result = await client.read_resource("data://multi")
# Read from proxy server
- async with Client(proxy_server) as client:
+ async with Client(proxy_server, mode="legacy") as client:
proxy_result = await client.read_resource("data://multi")
# Both should return the same number of contents
@@ -574,7 +574,7 @@ class TestResources:
with pytest.raises(
MCPError, match="Resource not found: 'resource://nonexistent'"
):
- async with Client(proxy_server) as client:
+ async with Client(proxy_server, mode="legacy") as client:
await client.read_resource("resource://nonexistent")
async def test_proxy_can_overwrite_proxied_resource(self, proxy_server):
@@ -586,7 +586,7 @@ class TestResources:
def overwritten_wave() -> str:
return "Overwritten wave! 🌊"
- async with Client(proxy_server) as client:
+ async with Client(proxy_server, mode="legacy") as client:
result = await client.read_resource("resource://wave")
assert isinstance(result[0], TextResourceContents)
assert result[0].text == "Overwritten wave! 🌊"
@@ -600,7 +600,7 @@ class TestResources:
def overwritten_wave() -> str:
return "Overwritten wave! 🌊"
- async with Client(proxy_server) as client:
+ async with Client(proxy_server, mode="legacy") as client:
resources = await client.list_resources()
wave_resource = next(
r for r in resources if str(r.uri) == "resource://wave"
@@ -627,15 +627,15 @@ class TestResourceTemplates:
async def test_list_resource_templates_same_as_original(
self, fastmcp_server, proxy_server
):
- async with Client(fastmcp_server) as original_client:
+ async with Client(fastmcp_server, mode="legacy") as original_client:
result = await original_client.list_resource_templates()
- async with Client(proxy_server) as proxy_client:
+ async with Client(proxy_server, mode="legacy") as proxy_client:
proxy_result = await proxy_client.list_resource_templates()
assert proxy_result == result
@pytest.mark.parametrize("id", [1, 2, 3])
async def test_read_resource_template(self, proxy_server: FastMCPProxy, id: int):
- async with Client(proxy_server) as client:
+ async with Client(proxy_server, mode="legacy") as client:
result = await client.read_resource(f"data://user/{id}")
assert isinstance(result[0], TextResourceContents)
assert json.loads(result[0].text) == USERS[id - 1]
@@ -643,9 +643,9 @@ class TestResourceTemplates:
async def test_read_resource_template_same_as_original(
self, fastmcp_server, proxy_server
):
- async with Client(fastmcp_server) as client:
+ async with Client(fastmcp_server, mode="legacy") as client:
result = await client.read_resource("data://user/1")
- async with Client(proxy_server) as client:
+ async with Client(proxy_server, mode="legacy") as client:
proxy_result = await client.read_resource("data://user/1")
assert proxy_result == result
@@ -654,11 +654,11 @@ class TestResourceTemplates:
):
"""Test that proxy template correctly returns all resource contents."""
# Read from original server
- async with Client(fastmcp_server) as client:
+ async with Client(fastmcp_server, mode="legacy") as client:
original_result = await client.read_resource("data://multi/test123")
# Read from proxy server
- async with Client(proxy_server) as client:
+ async with Client(proxy_server, mode="legacy") as client:
proxy_result = await client.read_resource("data://multi/test123")
# Both should return the same number of contents
@@ -696,7 +696,7 @@ class TestResourceTemplates:
}
)
- async with Client(proxy_server) as client:
+ async with Client(proxy_server, mode="legacy") as client:
result = await client.read_resource("data://user/1")
assert isinstance(result[0], TextResourceContents)
user_data = json.loads(result[0].text)
@@ -712,7 +712,7 @@ class TestResourceTemplates:
def overwritten_get_user(user_id: str) -> dict[str, Any]:
return {"id": user_id, "name": "Overwritten User", "active": True}
- async with Client(proxy_server) as client:
+ async with Client(proxy_server, mode="legacy") as client:
templates = await client.list_resource_templates()
user_template = next(
t for t in templates if t.uri_template == "data://user/{user_id}"
@@ -730,8 +730,8 @@ class TestResourceTemplateQueryParams:
def get_data(id: str, format: str = "json") -> str:
return f"id={id} format={format}"
- proxy = create_proxy(Client(remote))
- async with Client(proxy) as client:
+ proxy = create_proxy(Client(remote, mode="legacy"))
+ async with Client(proxy, mode="legacy") as client:
result = await client.read_resource("data://123?format=xml")
assert isinstance(result[0], TextResourceContents)
assert result[0].text == "id=123 format=xml"
@@ -743,8 +743,8 @@ class TestResourceTemplateQueryParams:
def get_data(id: str, format: str = "json") -> str:
return f"id={id} format={format}"
- proxy = create_proxy(Client(remote))
- async with Client(proxy) as client:
+ proxy = create_proxy(Client(remote, mode="legacy"))
+ async with Client(proxy, mode="legacy") as client:
result = await client.read_resource("data://123")
assert isinstance(result[0], TextResourceContents)
assert result[0].text == "id=123 format=json"
@@ -756,8 +756,8 @@ class TestResourceTemplateQueryParams:
def get_data(id: str, limit: int = 10, offset: int = 0) -> str:
return f"id={id} limit={limit} offset={offset}"
- proxy = create_proxy(Client(remote))
- async with Client(proxy) as client:
+ proxy = create_proxy(Client(remote, mode="legacy"))
+ async with Client(proxy, mode="legacy") as client:
result = await client.read_resource("data://abc?limit=5&offset=20")
assert isinstance(result[0], TextResourceContents)
assert result[0].text == "id=abc limit=5 offset=20"
@@ -769,8 +769,8 @@ class TestResourceTemplateQueryParams:
def get_data(id: str) -> str:
return f"id={id}"
- proxy = create_proxy(Client(remote))
- async with Client(proxy) as client:
+ proxy = create_proxy(Client(remote, mode="legacy"))
+ async with Client(proxy, mode="legacy") as client:
result = await client.read_resource("data://a%2Fb")
assert isinstance(result[0], TextResourceContents)
assert result[0].text == "id=a/b"
@@ -782,8 +782,8 @@ class TestResourceTemplateQueryParams:
def get_data(id: str, api_version: str = "v1") -> str:
return f"id={id} api_version={api_version}"
- proxy = create_proxy(Client(remote))
- async with Client(proxy) as client:
+ proxy = create_proxy(Client(remote, mode="legacy"))
+ async with Client(proxy, mode="legacy") as client:
result = await client.read_resource("data://123?api-version=v2")
assert isinstance(result[0], TextResourceContents)
assert result[0].text == "id=123 api_version=v2"
@@ -803,8 +803,8 @@ class TestResourceTemplateQueryParams:
def get_data(id: str, api_version: str = "v1") -> str:
return f"id={id} api_version={api_version}"
- proxy = create_proxy(Client(remote))
- async with Client(proxy) as client:
+ proxy = create_proxy(Client(remote, mode="legacy"))
+ async with Client(proxy, mode="legacy") as client:
result = await client.read_resource("data://123?api-version=a%2Fb")
assert isinstance(result[0], TextResourceContents)
assert result[0].text == "id=123 api_version=a/b"
@@ -825,23 +825,23 @@ class TestPrompts:
]
async def test_list_prompts_same_as_original(self, fastmcp_server, proxy_server):
- async with Client(fastmcp_server) as client:
+ async with Client(fastmcp_server, mode="legacy") as client:
result = await client.list_prompts()
- async with Client(proxy_server) as client:
+ async with Client(proxy_server, mode="legacy") as client:
proxy_result = await client.list_prompts()
assert proxy_result == result
async def test_render_prompt_same_as_original(
self, fastmcp_server: FastMCP, proxy_server: FastMCPProxy
):
- async with Client(fastmcp_server) as client:
+ async with Client(fastmcp_server, mode="legacy") as client:
result = await client.get_prompt("welcome", {"name": "Alice"})
- async with Client(proxy_server) as client:
+ async with Client(proxy_server, mode="legacy") as client:
proxy_result = await client.get_prompt("welcome", {"name": "Alice"})
assert proxy_result == result
async def test_render_prompt_calls_prompt(self, proxy_server):
- async with Client(proxy_server) as client:
+ async with Client(proxy_server, mode="legacy") as client:
result = await client.get_prompt("welcome", {"name": "Alice"})
assert result.messages[0].role == "user"
assert isinstance(result.messages[0].content, TextContent)
@@ -856,7 +856,7 @@ class TestPrompts:
def welcome(name: str, extra: str = "friend") -> str:
return f"Overwritten welcome, {name}! You are my {extra}."
- async with Client(proxy_server) as client:
+ async with Client(proxy_server, mode="legacy") as client:
result = await client.get_prompt(
"welcome", {"name": "Alice", "extra": "colleague"}
)
@@ -876,7 +876,7 @@ class TestPrompts:
def welcome(name: str, extra: str = "friend") -> str:
return f"Overwritten welcome, {name}! You are my {extra}."
- async with Client(proxy_server) as client:
+ async with Client(proxy_server, mode="legacy") as client:
prompts = await client.list_prompts()
welcome_prompt = next(p for p in prompts if p.name == "welcome")
# Check that the overwritten prompt has the additional 'extra' parameter
@@ -887,9 +887,9 @@ class TestPrompts:
self, fastmcp_server: FastMCP, proxy_server: FastMCPProxy
):
"""Test that ProxyPrompt preserves ImageContent without lossy conversion."""
- async with Client(fastmcp_server) as client:
+ async with Client(fastmcp_server, mode="legacy") as client:
result = await client.get_prompt("image_prompt")
- async with Client(proxy_server) as client:
+ async with Client(proxy_server, mode="legacy") as client:
proxy_result = await client.get_prompt("image_prompt")
# The proxy result should match the original exactly
@@ -1188,7 +1188,10 @@ class TestProxyOutputSchemaEnforcement:
async def _call_without_validating(self, server: FastMCP, tool: str):
"""Call through a client that does not enforce the schema itself."""
- client = Client(server)
+ # The proxy backend here is a legacy-era ProxyClient, so pin the end
+ # client to the handshake era too; the modern-end-client-through-proxy
+ # path is the separate proxy era-mirroring workstream.
+ client = Client(server, mode="legacy")
client._transport_options = TransportOptions(
session_class=_ForwardingClientSession
)
@@ -1230,7 +1233,8 @@ class TestProxyOutputSchemaEnforcement:
ProxyProvider(lambda: ProxyClient(backend_violating_its_schema))
)
- async with Client(proxy) as client:
+ # Legacy-era ProxyClient backend: pin the end client to match (see above).
+ async with Client(proxy, mode="legacy") as client:
with pytest.raises(RuntimeError, match="Invalid structured content"):
await client.call_tool_mcp("undeclared_status", {})
@@ -1275,7 +1279,8 @@ class TestProxyOutputSchemaEnforcement:
proxy = FastMCP("Proxy")
proxy.add_provider(ProxyProvider(lambda: ProxyClient(backend)))
- async with Client(proxy) as client:
+ # Legacy-era ProxyClient backend: pin the end client to match (see above).
+ async with Client(proxy, mode="legacy") as client:
await client.call_tool("echo", {"n": 1})
lists_after_first = counts["list"]
@@ -1349,8 +1354,14 @@ class TestProxyForwardingAppliesToEveryBackendClient:
return mcp
- async def _forwarded(self, server: FastMCP, tool: str = "status"):
- client = Client(server)
+ async def _forwarded(
+ self, server: FastMCP, tool: str = "status", mode: str = "auto"
+ ):
+ # `mode` follows the proxy backend's era: a modern-capable backend (plain
+ # Client / single-server config) lets the end client stay on the default
+ # auto era, while a legacy-only backend (multi-server config) needs the end
+ # client pinned to legacy until proxy era-mirroring lands.
+ client = Client(server, mode=mode)
client._transport_options = TransportOptions(
session_class=_ForwardingClientSession
)
@@ -1381,7 +1392,9 @@ class TestProxyForwardingAppliesToEveryBackendClient:
config = MCPConfig.from_dict(
{"mcpServers": {"a": {"url": url}, "b": {"url": url}}}
)
- result = await self._forwarded(create_proxy(Client(config)), "a_status")
+ result = await self._forwarded(
+ create_proxy(Client(config)), "a_status", mode="legacy"
+ )
assert result.is_error is False
assert result.structured_content == {"status": "weird"}
diff --git a/tests/server/providers/proxy/test_stateful_proxy_client.py b/tests/server/providers/proxy/test_stateful_proxy_client.py
index 3ebb9d536..f9389d608 100644
--- a/tests/server/providers/proxy/test_stateful_proxy_client.py
+++ b/tests/server/providers/proxy/test_stateful_proxy_client.py
@@ -95,8 +95,12 @@ class TestStatefulProxyClient:
results["logger_b"] = message
async with (
- Client(stateful_proxy_server, log_handler=log_handler_a) as client_a,
- Client(stateful_proxy_server, log_handler=log_handler_b) as client_b,
+ Client(
+ stateful_proxy_server, mode="legacy", log_handler=log_handler_a
+ ) as client_a,
+ Client(
+ stateful_proxy_server, mode="legacy", log_handler=log_handler_b
+ ) as client_b,
):
async with create_task_group() as tg:
tg.start_soon(
@@ -115,7 +119,7 @@ class TestStatefulProxyClient:
async def test_stateful_proxy(self, stateful_proxy_server: FastMCP):
"""Test that the state shared across multiple calls for the same client (fixes #959)."""
- async with Client(stateful_proxy_server) as client:
+ async with Client(stateful_proxy_server, mode="legacy") as client:
with pytest.raises(ToolError, match="Value not found"):
await client.call_tool("stateful_get", {})
@@ -126,7 +130,7 @@ class TestStatefulProxyClient:
async def test_stateless_proxy(self, stateless_server: str):
"""Test that the state will not be shared across different calls,
even if they are from the same client."""
- async with Client(stateless_server) as client:
+ async with Client(stateless_server, mode="legacy") as client:
await client.call_tool("stateful_put", {"value": 1})
with pytest.raises(ToolError, match="Value not found"):
@@ -154,7 +158,7 @@ class TestStatefulProxyClient:
multi_proxy_mcp.mount(proxy_mcp_a, namespace="a")
multi_proxy_mcp.mount(proxy_mcp_b, namespace="b")
- async with Client(multi_proxy_mcp) as client:
+ async with Client(multi_proxy_mcp, mode="legacy") as client:
result_a = await client.call_tool("a_tool_a", {})
result_b = await client.call_tool("b_tool_b", {})
assert result_a.data == "a"
@@ -202,7 +206,7 @@ class TestStatefulProxyClient:
# related_request_id routing for server-initiated messages.
async with run_server_async(proxy) as proxy_url:
async with Client(
- proxy_url, elicitation_handler=elicitation_handler
+ proxy_url, mode="legacy", elicitation_handler=elicitation_handler
) as client:
result1 = await client.call_tool("ask_name", {})
assert result1.data == "Hello, Alice!"
diff --git a/tests/server/tasks/test_concurrent_dependencies.py b/tests/server/tasks/test_concurrent_dependencies.py
index eb5af1bb1..edc3b6a6a 100644
--- a/tests/server/tasks/test_concurrent_dependencies.py
+++ b/tests/server/tasks/test_concurrent_dependencies.py
@@ -30,7 +30,7 @@ async def test_concurrent_foreground_tools_with_context():
results.append(name)
return f"done:{name}"
- async with Client(mcp) as client:
+ async with Client(mcp, mode="legacy") as client:
tasks = [client.call_tool("slow_tool", {"name": f"task-{i}"}) for i in range(4)]
outcomes = await asyncio.gather(*tasks)
@@ -56,7 +56,7 @@ async def test_concurrent_foreground_tools_with_progress():
await progress.increment()
return f"done:{name}"
- async with Client(mcp) as client:
+ async with Client(mcp, mode="legacy") as client:
tasks = [
client.call_tool(
"variable_tool", {"name": f"t-{i}", "delay": 0.01 * (i + 1)}
@@ -80,7 +80,7 @@ async def test_concurrent_background_tasks_with_context():
await asyncio.sleep(0.05)
return f"bg:{name}"
- async with Client(mcp) as client:
+ async with Client(mcp, mode="legacy") as client:
task_handles = [
await client.call_tool("bg_tool", {"name": f"bg-{i}"}, task=True)
for i in range(4)
@@ -109,7 +109,7 @@ async def test_concurrent_background_tasks_with_progress():
await progress.increment()
return f"bg:{name}"
- async with Client(mcp) as client:
+ async with Client(mcp, mode="legacy") as client:
task_handles = [
await client.call_tool(
"bg_progress_tool",
@@ -137,7 +137,7 @@ async def test_dependency_aenter_returns_fresh_instances():
instances.append(ctx)
return "ok"
- async with Client(mcp) as client:
+ async with Client(mcp, mode="legacy") as client:
await asyncio.gather(
client.call_tool("capture_context", {}),
client.call_tool("capture_context", {}),
@@ -161,7 +161,7 @@ async def test_progress_aenter_returns_fresh_instances():
await progress.increment()
return "ok"
- async with Client(mcp) as client:
+ async with Client(mcp, mode="legacy") as client:
await asyncio.gather(
client.call_tool("capture_progress", {}),
client.call_tool("capture_progress", {}),
@@ -187,7 +187,7 @@ async def test_sync_context_functions_work_in_background_without_deps():
headers = get_http_headers()
return {"has_headers": str(bool(headers))}
- async with Client(mcp) as client:
+ async with Client(mcp, mode="legacy") as client:
task = await client.call_tool("bare_sync_access", {}, task=True)
result = await task.result()
assert result.data == {"has_headers": "False"}
@@ -207,7 +207,7 @@ async def test_sync_context_functions_work_in_background_with_context():
"is_background": str(ctx.is_background_task),
}
- async with Client(mcp) as client:
+ async with Client(mcp, mode="legacy") as client:
task = await client.call_tool("context_sync_access", {}, task=True)
result = await task.result()
assert result.data["is_background"] == "True"
diff --git a/tests/server/tasks/test_context_background_task.py b/tests/server/tasks/test_context_background_task.py
index 0441bf8a0..42f92fa30 100644
--- a/tests/server/tasks/test_context_background_task.py
+++ b/tests/server/tasks/test_context_background_task.py
@@ -1,7 +1,7 @@
"""Tests for Context background task support (SEP-1686).
Tests Context API surface (unit) and background task elicitation (integration).
-Integration tests use Client(mcp) with the real memory:// Docket backend —
+Integration tests use Client(mcp, mode="legacy") with the real memory:// Docket backend —
no mocking of Redis, Docket, or session internals.
"""
@@ -90,7 +90,7 @@ async def test_task_session_is_released_after_client_disconnect():
async def work() -> str:
return "done"
- async with Client(mcp) as client:
+ async with Client(mcp, mode="legacy") as client:
task = await client.call_tool("work", task=True)
await task.result()
assert len(_task_sessions) == 1
@@ -357,7 +357,7 @@ class TestElicitFailFast:
"fastmcp.server.tasks.notifications.push_notification",
side_effect=ConnectionError("Redis queue unavailable"),
):
- async with Client(mcp) as client:
+ async with Client(mcp, mode="legacy") as client:
task = await client.call_tool("failfast_tool", {}, task=True)
await asyncio.wait_for(elicit_started.wait(), timeout=5.0)
await task.wait(timeout=10.0)
@@ -389,14 +389,14 @@ class TestContextDocumentation:
# =============================================================================
-# Integration tests: Client(mcp) + memory:// Docket backend
+# Integration tests: Client(mcp, mode="legacy") + memory:// Docket backend
# =============================================================================
class TestBackgroundTaskIntegration:
"""Integration tests for background task context using real Docket memory backend.
- These tests use Client(mcp) with the memory:// broker — no mocking.
+ These tests use Client(mcp, mode="legacy") with the memory:// broker — no mocking.
The memory:// backend provides a fully functional in-memory Redis store
that Docket uses automatically when running tests.
"""
@@ -414,7 +414,7 @@ class TestBackgroundTaskIntegration:
progress_reported.set()
return "done"
- async with Client(mcp) as client:
+ async with Client(mcp, mode="legacy") as client:
task = await client.call_tool("progress_tool", {}, task=True)
await asyncio.wait_for(progress_reported.wait(), timeout=5.0)
await task.wait(timeout=5.0)
@@ -435,7 +435,7 @@ class TestBackgroundTaskIntegration:
task_completed.set()
return "ok"
- async with Client(mcp) as client:
+ async with Client(mcp, mode="legacy") as client:
task = await client.call_tool("verify_wiring", {}, task=True)
await asyncio.wait_for(task_completed.wait(), timeout=5.0)
await task.wait(timeout=5.0)
@@ -479,7 +479,7 @@ class TestBackgroundTaskIntegration:
assert snapshot["origin_request_id"] == origin
return "ok"
- async with Client(mcp) as client:
+ async with Client(mcp, mode="legacy") as client:
task = await client.call_tool("check_origin_request_id", {}, task=True)
result = await task.result()
assert result.data == "ok"
@@ -514,7 +514,9 @@ class TestBackgroundTaskIntegration:
stop_reason="endTurn",
)
- async with Client(mcp, sampling_handler=sampling_handler) as client:
+ async with Client(
+ mcp, mode="legacy", sampling_handler=sampling_handler
+ ) as client:
task = await client.call_tool("ask_client", {}, task=True)
result = await task.result()
@@ -535,7 +537,7 @@ class TestBackgroundTaskIntegration:
async def handler(message, response_type, params, ctx):
return ElicitResult(action="accept", content={"value": "Bob"})
- async with Client(mcp, elicitation_handler=handler) as client:
+ async with Client(mcp, mode="legacy", elicitation_handler=handler) as client:
task = await client.call_tool("ask_name", {}, task=True)
await task.wait(timeout=10.0)
result = await task.result()
@@ -557,7 +559,7 @@ class TestBackgroundTaskIntegration:
async def handler(message, response_type, params, ctx):
return ElicitResult(action="decline")
- async with Client(mcp, elicitation_handler=handler) as client:
+ async with Client(mcp, mode="legacy", elicitation_handler=handler) as client:
task = await client.call_tool("optional_input", {}, task=True)
await task.wait(timeout=10.0)
result = await task.result()
@@ -583,7 +585,7 @@ class TestBackgroundTaskIntegration:
async def handler(message, response_type, params, ctx):
return ElicitResult(action="accept", content={"name": "Alice", "age": 30})
- async with Client(mcp, elicitation_handler=handler) as client:
+ async with Client(mcp, mode="legacy", elicitation_handler=handler) as client:
task = await client.call_tool("get_user_info", {}, task=True)
await task.wait(timeout=10.0)
result = await task.result()
@@ -597,7 +599,7 @@ class TestBackgroundTaskIntegration:
async def simple_tool() -> str:
return "done"
- async with Client(mcp) as client:
+ async with Client(mcp, mode="legacy") as client:
task = await client.call_tool("simple_tool", {}, task=True)
await task.wait(timeout=5.0)
@@ -615,7 +617,7 @@ class TestBackgroundTaskIntegration:
class TestAccessTokenInBackgroundTasks:
"""Tests for access token availability in background tasks (#3095).
- Integration tests use Client(mcp) with the real memory:// Docket backend.
+ Integration tests use Client(mcp, mode="legacy") with the real memory:// Docket backend.
The token snapshot/restore round-trip flows through actual Redis (fakeredis).
Note: async tests run in isolated asyncio tasks, so ContextVar changes
@@ -641,7 +643,7 @@ class TestAccessTokenInBackgroundTasks:
)
auth_context_var.set(AuthenticatedUser(test_token))
- async with Client(mcp) as client:
+ async with Client(mcp, mode="legacy") as client:
task = await client.call_tool("check_token", {}, task=True)
result = await task.result()
assert result.data == "roundtrip-jwt|test-client"
@@ -655,7 +657,7 @@ class TestAccessTokenInBackgroundTasks:
token = get_access_token()
return "no-token" if token is None else token.token
- async with Client(mcp) as client:
+ async with Client(mcp, mode="legacy") as client:
task = await client.call_tool("check_token", {}, task=True)
result = await task.result()
assert result.data == "no-token"
diff --git a/tests/server/tasks/test_custom_subclass_tasks.py b/tests/server/tasks/test_custom_subclass_tasks.py
index 2077e066c..80233e8fc 100644
--- a/tests/server/tasks/test_custom_subclass_tasks.py
+++ b/tests/server/tasks/test_custom_subclass_tasks.py
@@ -67,14 +67,14 @@ def custom_tool_server():
async def test_custom_tool_sync_execution(custom_tool_server):
"""Custom tool executes synchronously when no task metadata."""
- async with Client(custom_tool_server) as client:
+ async with Client(custom_tool_server, mode="legacy") as client:
result = await client.call_tool("custom_tool", {})
assert "Custom tool executed" in str(result)
async def test_custom_tool_background_execution(custom_tool_server):
"""Custom tool executes as background task when task=True."""
- async with Client(custom_tool_server) as client:
+ async with Client(custom_tool_server, mode="legacy") as client:
task = await client.call_tool("custom_tool", {}, task=True)
assert task is not None
@@ -88,7 +88,7 @@ async def test_custom_tool_background_execution(custom_tool_server):
async def test_custom_tool_with_arguments(custom_tool_server):
"""Custom tool receives arguments correctly in background execution."""
- async with Client(custom_tool_server) as client:
+ async with Client(custom_tool_server, mode="legacy") as client:
task = await client.call_tool("custom_logic", {"duration": 1}, task=True)
assert task is not None
@@ -98,7 +98,7 @@ async def test_custom_tool_with_arguments(custom_tool_server):
async def test_custom_tool_forbidden_sync_only(custom_tool_server):
"""Custom tool with forbidden mode executes sync only."""
- async with Client(custom_tool_server) as client:
+ async with Client(custom_tool_server, mode="legacy") as client:
# Sync execution works
result = await client.call_tool("custom_forbidden", {})
assert "Sync only" in str(result)
@@ -106,7 +106,7 @@ async def test_custom_tool_forbidden_sync_only(custom_tool_server):
async def test_custom_tool_forbidden_rejects_task(custom_tool_server):
"""Custom tool with forbidden mode returns error for task request."""
- async with Client(custom_tool_server) as client:
+ async with Client(custom_tool_server, mode="legacy") as client:
task = await client.call_tool("custom_forbidden", {}, task=True)
# Should return immediately with error
diff --git a/tests/server/tasks/test_notifications.py b/tests/server/tasks/test_notifications.py
index ca7abfe1a..a2908c0b2 100644
--- a/tests/server/tasks/test_notifications.py
+++ b/tests/server/tasks/test_notifications.py
@@ -1,7 +1,7 @@
"""Tests for distributed notification queue (SEP-1686).
Integration tests verify that the notification queue works end-to-end
-using Client(mcp) with the real memory:// Docket backend.
+using Client(mcp, mode="legacy") with the real memory:// Docket backend.
No mocking of Redis, sessions, or Docket internals.
"""
@@ -53,6 +53,7 @@ class TestNotificationIntegration:
async with Client(
mcp,
+ mode="legacy",
elicitation_handler=elicitation_handler,
) as client:
task = await client.call_tool("elicit_tool", {}, task=True)
@@ -107,7 +108,7 @@ class TestNotificationIntegration:
count_before = get_subscriber_count()
- async with Client(mcp) as client:
+ async with Client(mcp, mode="legacy") as client:
task = await client.call_tool("lifecycle_tool", {}, task=True)
await asyncio.wait_for(tool_started.wait(), timeout=5.0)
diff --git a/tests/server/tasks/test_progress_dependency.py b/tests/server/tasks/test_progress_dependency.py
index 6cc35996a..2370b6e34 100644
--- a/tests/server/tasks/test_progress_dependency.py
+++ b/tests/server/tasks/test_progress_dependency.py
@@ -16,7 +16,7 @@ async def test_progress_in_immediate_execution():
await progress.set_message("Testing")
return "done"
- async with Client(mcp) as client:
+ async with Client(mcp, mode="legacy") as client:
result = await client.call_tool("test_tool", {})
from mcp_types import TextContent
@@ -35,7 +35,7 @@ async def test_progress_in_background_task():
await progress.set_message("Step 1")
return "done"
- async with Client(mcp) as client:
+ async with Client(mcp, mode="legacy") as client:
task = await client.call_tool("test_task", {}, task=True)
result = await task.result()
from mcp_types import TextContent
@@ -55,7 +55,7 @@ async def test_progress_tracks_multiple_increments():
await progress.increment()
return "counted"
- async with Client(mcp) as client:
+ async with Client(mcp, mode="legacy") as client:
result = await client.call_tool("count_to_ten", {})
from mcp_types import TextContent
@@ -86,7 +86,7 @@ async def test_progress_status_message_in_background_task():
await progress.increment()
return "done"
- async with Client(mcp) as client:
+ async with Client(mcp, mode="legacy") as client:
task = await client.call_tool("task_with_progress", {}, task=True)
# Wait for first step to start
@@ -141,7 +141,7 @@ async def test_inmemory_progress_state():
"message": progress.message,
}
- async with Client(mcp) as client:
+ async with Client(mcp, mode="legacy") as client:
result = await client.call_tool("test_tool", {})
from mcp_types import TextContent
diff --git a/tests/server/tasks/test_resource_task_meta_parameter.py b/tests/server/tasks/test_resource_task_meta_parameter.py
index 86d36b3a4..1a5caeffd 100644
--- a/tests/server/tasks/test_resource_task_meta_parameter.py
+++ b/tests/server/tasks/test_resource_task_meta_parameter.py
@@ -124,7 +124,7 @@ class TestResourceTaskMetaClientIntegration:
async def immediate_resource() -> str:
return "hello"
- async with Client(server) as client:
+ async with Client(server, mode="legacy") as client:
result = await client.read_resource("data://test")
# Should get ReadResourceResult directly
@@ -138,7 +138,7 @@ class TestResourceTaskMetaClientIntegration:
async def task_resource() -> str:
return "hello"
- async with Client(server) as client:
+ async with Client(server, mode="legacy") as client:
from fastmcp.client.tasks import ResourceTask
task = await client.read_resource("data://test", task=True)
@@ -157,7 +157,7 @@ class TestResourceTaskMetaClientIntegration:
async def get_item(id: str) -> str:
return f"Item {id}"
- async with Client(server) as client:
+ async with Client(server, mode="legacy") as client:
from fastmcp.client.tasks import ResourceTask
task = await client.read_resource("item://42", task=True)
@@ -187,7 +187,7 @@ class TestResourceTaskMetaDirectServerCall:
# Should get CreateTaskResult since we provided task_meta
return f"Created task: {result.task.task_id}"
- async with Client(server) as client:
+ async with Client(server, mode="legacy") as client:
result = await client.call_tool("outer_tool", {})
assert "Created task:" in str(result)
@@ -206,7 +206,7 @@ class TestResourceTaskMetaDirectServerCall:
# Should get ResourceResult directly
return f"Got result: {result.contents[0].content}"
- async with Client(server) as client:
+ async with Client(server, mode="legacy") as client:
result = await client.call_tool("outer_tool", {})
assert "Got result: inner data" in str(result)
@@ -223,7 +223,7 @@ class TestResourceTaskMetaDirectServerCall:
result = await server.read_resource("item://99", task_meta=TaskMeta())
return f"Created task: {result.task.task_id}"
- async with Client(server) as client:
+ async with Client(server, mode="legacy") as client:
result = await client.call_tool("outer_tool", {})
assert "Created task:" in str(result)
@@ -243,7 +243,7 @@ class TestResourceTaskMetaDirectServerCall:
)
return f"Task TTL: {result.task.ttl}"
- async with Client(server) as client:
+ async with Client(server, mode="legacy") as client:
result = await client.call_tool("outer_tool", {})
assert "Task TTL: 45000" in str(result)
@@ -274,7 +274,7 @@ class TestResourceTaskMetaTypeNarrowing:
async def task_resource() -> str:
return "hello"
- async with Client(server) as client:
+ async with Client(server, mode="legacy") as client:
# Need to use client to get full task infrastructure
from fastmcp.client.tasks import ResourceTask
diff --git a/tests/server/tasks/test_server_tasks_parameter.py b/tests/server/tasks/test_server_tasks_parameter.py
index acb30811e..45b777d6a 100644
--- a/tests/server/tasks/test_server_tasks_parameter.py
+++ b/tests/server/tasks/test_server_tasks_parameter.py
@@ -35,7 +35,7 @@ async def test_server_tasks_true_defaults_all_components():
async def my_resource() -> str:
return "resource result"
- async with Client(mcp) as client:
+ async with Client(mcp, mode="legacy") as client:
# Verify all task-enabled components are registered with docket
# Components use prefixed keys: tool:name, prompt:name, resource:uri
docket = mcp.docket
@@ -82,7 +82,7 @@ async def test_server_tasks_false_defaults_all_components():
async def my_resource() -> str:
return "resource result"
- async with Client(mcp) as client:
+ async with Client(mcp, mode="legacy") as client:
# Tool with mode="forbidden" returns error when called with task=True
tool_task = await client.call_tool("my_tool", task=True, raise_on_error=False)
assert tool_task.returned_immediately
@@ -107,7 +107,7 @@ async def test_server_tasks_none_defaults_to_false():
async def my_tool() -> str:
return "tool result"
- async with Client(mcp) as client:
+ async with Client(mcp, mode="legacy") as client:
# Tool should NOT support background execution (mode="forbidden" from default)
tool_task = await client.call_tool("my_tool", task=True, raise_on_error=False)
assert tool_task.returned_immediately
@@ -128,7 +128,7 @@ async def test_component_explicit_false_overrides_server_true():
async def default_tool() -> str:
return "background result"
- async with Client(mcp) as client:
+ async with Client(mcp, mode="legacy") as client:
# Verify docket registration matches task settings (prefixed keys)
docket = mcp.docket
assert docket is not None
@@ -163,7 +163,7 @@ async def test_component_explicit_true_overrides_server_false():
async def default_tool() -> str:
return "immediate result"
- async with Client(mcp) as client:
+ async with Client(mcp, mode="legacy") as client:
# Verify docket registration matches task settings (prefixed keys)
docket = mcp.docket
assert docket is not None
@@ -224,7 +224,7 @@ async def test_mixed_explicit_and_inherited():
async def explicit_false_resource() -> str:
return "explicit False"
- async with Client(mcp) as client:
+ async with Client(mcp, mode="legacy") as client:
# Verify docket registration matches task settings
# Components use prefixed keys: tool:name, prompt:name, resource:uri
docket = mcp.docket
@@ -282,7 +282,7 @@ async def test_server_tasks_parameter_sets_component_defaults():
async def tool_inherits_true() -> str:
return "tool result"
- async with Client(mcp) as client:
+ async with Client(mcp, mode="legacy") as client:
# Tool inherits tasks=True from server
tool_task = await client.call_tool("tool_inherits_true", task=True)
assert not tool_task.returned_immediately
@@ -294,7 +294,7 @@ async def test_server_tasks_parameter_sets_component_defaults():
async def tool_inherits_false() -> str:
return "tool result"
- async with Client(mcp2) as client:
+ async with Client(mcp2, mode="legacy") as client:
# Tool inherits tasks=False (mode="forbidden") - returns error
tool_task = await client.call_tool(
"tool_inherits_false", task=True, raise_on_error=False
@@ -318,7 +318,7 @@ async def test_resource_template_inherits_server_tasks_default():
async def templated_resource(item_id: str) -> str:
return f"resource {item_id}"
- async with Client(mcp) as client:
+ async with Client(mcp, mode="legacy") as client:
# Template should support background execution
resource_task = await client.read_resource("test://123", task=True)
assert not resource_task.returned_immediately
@@ -345,7 +345,7 @@ async def test_multiple_components_same_name_different_tasks():
async def shared_name_prompt() -> str:
return "prompt result"
- async with Client(mcp) as client:
+ async with Client(mcp, mode="legacy") as client:
# Tool with explicit True should support background execution
tool_task = await client.call_tool("shared_name", task=True)
assert not tool_task.returned_immediately
@@ -368,7 +368,7 @@ async def test_task_with_custom_tool_name():
mcp.tool(my_function, name="custom-tool-name")
- async with Client(mcp) as client:
+ async with Client(mcp, mode="legacy") as client:
# Verify the tool is registered with its custom name in Docket (prefixed key)
docket = mcp.docket
assert docket is not None
@@ -398,7 +398,7 @@ async def test_task_with_custom_resource_name():
async def my_resource_func() -> str:
return "result from custom-named resource"
- async with Client(mcp) as client:
+ async with Client(mcp, mode="legacy") as client:
# Verify the resource is registered with its key (prefixed URI) in Docket
docket = mcp.docket
assert docket is not None
@@ -428,7 +428,7 @@ async def test_task_with_custom_template_name():
async def my_template_func(item_id: str) -> str:
return f"result for {item_id}"
- async with Client(mcp) as client:
+ async with Client(mcp, mode="legacy") as client:
# Verify the template is registered with its key (prefixed uri_template) in Docket
docket = mcp.docket
assert docket is not None
diff --git a/tests/server/tasks/test_snapshot_restore.py b/tests/server/tasks/test_snapshot_restore.py
index 946e1bf1b..09a5241d1 100644
--- a/tests/server/tasks/test_snapshot_restore.py
+++ b/tests/server/tasks/test_snapshot_restore.py
@@ -39,7 +39,7 @@ async def test_snapshot_restored_before_user_code_runs():
seen_cached.append(_recall_snapshot(info.task_id) is not None)
return "ok"
- async with Client(mcp) as client:
+ async with Client(mcp, mode="legacy") as client:
task = await client.call_tool("bare_tool", {}, task=True)
await task.result()
@@ -66,7 +66,7 @@ async def test_get_access_token_in_bg_task_without_context_dep():
)
auth_context_var.set(AuthenticatedUser(test_token))
- async with Client(mcp) as client:
+ async with Client(mcp, mode="legacy") as client:
task = await client.call_tool("bare_tool", {}, task=True)
await task.result()
@@ -89,7 +89,7 @@ async def test_restore_failure_is_nonfatal():
def boom(*_args, **_kwargs):
raise RuntimeError("simulated deserialization failure")
- async with Client(mcp) as client:
+ async with Client(mcp, mode="legacy") as client:
with patch.object(TaskContextSnapshot, "from_json", boom):
task = await client.call_tool("bare_tool", {}, task=True)
result = await task.result()
diff --git a/tests/server/tasks/test_task_capabilities.py b/tests/server/tasks/test_task_capabilities.py
index 3999c5dbe..e504cee53 100644
--- a/tests/server/tasks/test_task_capabilities.py
+++ b/tests/server/tasks/test_task_capabilities.py
@@ -18,7 +18,7 @@ async def test_capabilities_include_tasks():
async def test_tool() -> str:
return "test"
- async with Client(mcp) as client:
+ async with Client(mcp, mode="legacy") as client:
# Get server initialization result which includes capabilities
init_result = client.initialize_result
@@ -59,7 +59,7 @@ async def test_client_uses_task_capable_session():
async def test_tool() -> str:
return "test"
- async with Client(mcp) as client:
+ async with Client(mcp, mode="legacy") as client:
# Client should have connected successfully with task capabilities
assert client.initialize_result is not None
# Session should be a ClientSession (task-capable init uses standard session)
diff --git a/tests/server/tasks/test_task_config.py b/tests/server/tasks/test_task_config.py
index d6e095509..e10d7cff2 100644
--- a/tests/server/tasks/test_task_config.py
+++ b/tests/server/tasks/test_task_config.py
@@ -110,7 +110,7 @@ class TestToolModeEnforcement:
async def test_required_mode_without_task_returns_error(self, server):
"""Required mode raises error when called without task metadata."""
- async with Client(server) as client:
+ async with Client(server, mode="legacy") as client:
with pytest.raises(ToolError) as exc_info:
await client.call_tool("required_tool", {})
@@ -118,7 +118,7 @@ class TestToolModeEnforcement:
async def test_required_mode_with_task_succeeds(self, server):
"""Required mode succeeds when called with task metadata."""
- async with Client(server) as client:
+ async with Client(server, mode="legacy") as client:
task = await client.call_tool("required_tool", {}, task=True)
assert task is not None
result = await task.result()
@@ -126,7 +126,7 @@ class TestToolModeEnforcement:
async def test_forbidden_mode_with_task_returns_error(self, server):
"""Forbidden mode returns error when called with task metadata."""
- async with Client(server) as client:
+ async with Client(server, mode="legacy") as client:
# Call with task=True should fail
task = await client.call_tool(
"forbidden_tool", {}, task=True, raise_on_error=False
@@ -140,19 +140,19 @@ class TestToolModeEnforcement:
async def test_forbidden_mode_without_task_succeeds(self, server):
"""Forbidden mode succeeds when called without task metadata."""
- async with Client(server) as client:
+ async with Client(server, mode="legacy") as client:
result = await client.call_tool("forbidden_tool", {})
assert "forbidden result" in str(result)
async def test_optional_mode_without_task_succeeds(self, server):
"""Optional mode succeeds when called without task metadata."""
- async with Client(server) as client:
+ async with Client(server, mode="legacy") as client:
result = await client.call_tool("optional_tool", {})
assert "optional result" in str(result)
async def test_optional_mode_with_task_succeeds(self, server):
"""Optional mode succeeds when called with task metadata."""
- async with Client(server) as client:
+ async with Client(server, mode="legacy") as client:
task = await client.call_tool("optional_tool", {}, task=True)
assert task is not None
result = await task.result()
@@ -188,7 +188,7 @@ class TestResourceModeEnforcement:
"""Required mode returns error when read without task metadata."""
from mcp_types import METHOD_NOT_FOUND
- async with Client(server) as client:
+ async with Client(server, mode="legacy") as client:
with pytest.raises(MCPError) as exc_info:
await client.read_resource("resource://required")
@@ -203,7 +203,7 @@ class TestResourceModeEnforcement:
)
async def test_required_resource_with_task_succeeds(self, server):
"""Required mode succeeds when read with task metadata."""
- async with Client(server) as client:
+ async with Client(server, mode="legacy") as client:
task = await client.read_resource("resource://required", task=True)
assert task is not None
result = await task.result()
@@ -212,7 +212,7 @@ class TestResourceModeEnforcement:
async def test_forbidden_resource_without_task_succeeds(self, server):
"""Forbidden mode succeeds when read without task metadata."""
- async with Client(server) as client:
+ async with Client(server, mode="legacy") as client:
result = await client.read_resource("resource://forbidden")
assert "forbidden content" in str(result)
@@ -246,7 +246,7 @@ class TestPromptModeEnforcement:
"""Required mode returns error when called without task metadata."""
from mcp_types import METHOD_NOT_FOUND
- async with Client(server) as client:
+ async with Client(server, mode="legacy") as client:
with pytest.raises(MCPError) as exc_info:
await client.get_prompt("required_prompt")
@@ -261,7 +261,7 @@ class TestPromptModeEnforcement:
)
async def test_required_prompt_with_task_succeeds(self, server):
"""Required mode succeeds when called with task metadata."""
- async with Client(server) as client:
+ async with Client(server, mode="legacy") as client:
task = await client.get_prompt("required_prompt", task=True)
assert task is not None
result = await task.result()
@@ -270,7 +270,7 @@ class TestPromptModeEnforcement:
async def test_forbidden_prompt_without_task_succeeds(self, server):
"""Forbidden mode succeeds when called without task metadata."""
- async with Client(server) as client:
+ async with Client(server, mode="legacy") as client:
result = await client.get_prompt("forbidden_prompt")
assert isinstance(result.messages[0].content, TextContent)
assert "forbidden message" in str(result.messages[0].content)
@@ -287,7 +287,7 @@ class TestToolExecutionMetadata:
async def my_tool() -> str:
return "ok"
- async with Client(mcp) as client:
+ async with Client(mcp, mode="legacy") as client:
tools = await client.list_tools()
tool = next(t for t in tools if t.name == "my_tool")
assert isinstance(tool, MCPTool)
@@ -302,7 +302,7 @@ class TestToolExecutionMetadata:
async def my_tool() -> str:
return "ok"
- async with Client(mcp) as client:
+ async with Client(mcp, mode="legacy") as client:
tools = await client.list_tools()
tool = next(t for t in tools if t.name == "my_tool")
assert isinstance(tool, MCPTool)
@@ -317,7 +317,7 @@ class TestToolExecutionMetadata:
async def my_tool() -> str:
return "ok"
- async with Client(mcp) as client:
+ async with Client(mcp, mode="legacy") as client:
tools = await client.list_tools()
tool = next(t for t in tools if t.name == "my_tool")
assert tool.execution is None
diff --git a/tests/server/tasks/test_task_dependencies.py b/tests/server/tasks/test_task_dependencies.py
index 0aef545ee..1745f5e49 100644
--- a/tests/server/tasks/test_task_dependencies.py
+++ b/tests/server/tasks/test_task_dependencies.py
@@ -75,7 +75,7 @@ async def dependency_server():
async def test_background_tool_receives_docket_dependency(dependency_server):
"""Background tools can use CurrentDocket() and it resolves correctly."""
- async with Client(dependency_server) as client:
+ async with Client(dependency_server, mode="legacy") as client:
task = await client.call_tool("tool_with_docket_dependency", {}, task=True)
# Verify it's background
@@ -96,7 +96,7 @@ async def test_background_tool_receives_server_dependency(dependency_server):
"""Background tools can use CurrentFastMCP() and get the actual FastMCP server."""
dependency_server._injected_values.clear()
- async with Client(dependency_server) as client:
+ async with Client(dependency_server, mode="legacy") as client:
task = await client.call_tool("tool_with_server_dependency", {}, task=True)
# Verify background execution
@@ -116,7 +116,7 @@ async def test_background_tool_receives_custom_depends(dependency_server):
"""Background tools can use Depends() with custom functions."""
dependency_server._injected_values.clear()
- async with Client(dependency_server) as client:
+ async with Client(dependency_server, mode="legacy") as client:
task = await client.call_tool(
"tool_with_custom_dependency", {"value": 5}, task=True
)
@@ -137,7 +137,7 @@ async def test_background_tool_with_multiple_dependencies(dependency_server):
"""Background tools can have multiple dependencies injected simultaneously."""
dependency_server._injected_values.clear()
- async with Client(dependency_server) as client:
+ async with Client(dependency_server, mode="legacy") as client:
task = await client.call_tool(
"tool_with_multiple_dependencies", {"name": "test"}, task=True
)
@@ -170,7 +170,7 @@ async def test_background_prompt_receives_dependencies(dependency_server):
"""Background prompts can use dependency injection."""
dependency_server._injected_values.clear()
- async with Client(dependency_server) as client:
+ async with Client(dependency_server, mode="legacy") as client:
task = await client.get_prompt(
"prompt_with_server_dependency", {"topic": "AI"}, task=True
)
@@ -196,7 +196,7 @@ async def test_background_resource_receives_dependencies(dependency_server):
"""Background resources can use dependency injection."""
dependency_server._injected_values.clear()
- async with Client(dependency_server) as client:
+ async with Client(dependency_server, mode="legacy") as client:
task = await client.read_resource("file://data.txt", task=True)
assert not task.returned_immediately
@@ -219,7 +219,7 @@ async def test_foreground_tool_dependencies_unaffected(dependency_server):
dependency_server._injected_values.append(("sync_server", server))
return f"Sync: {server.name}"
- async with Client(dependency_server) as client:
+ async with Client(dependency_server, mode="legacy") as client:
await client.call_tool("sync_tool", {})
# Should execute immediately
@@ -248,7 +248,7 @@ async def test_dependency_context_managers_cleaned_up_in_background():
assert "exit" not in cleanup_called # Still open during execution
return f"Used: {conn}"
- async with Client(mcp) as client:
+ async with Client(mcp, mode="legacy") as client:
task = await client.call_tool("use_connection", {"name": "test"}, task=True)
result = await task
@@ -270,7 +270,7 @@ async def test_dependency_errors_propagate_to_task_failure():
) -> str:
return f"Got: {dep}"
- async with Client(mcp) as client:
+ async with Client(mcp, mode="legacy") as client:
task = await client.call_tool(
"tool_with_failing_dep", {"value": "test"}, task=True
)
diff --git a/tests/server/tasks/test_task_elicitation_relay.py b/tests/server/tasks/test_task_elicitation_relay.py
index 42362edd2..bf8d6e8b9 100644
--- a/tests/server/tasks/test_task_elicitation_relay.py
+++ b/tests/server/tasks/test_task_elicitation_relay.py
@@ -7,7 +7,7 @@ an elicitation/create request to the client session. The client's
elicitation_handler fires, and the relay pushes the response to Redis
for the blocked worker.
-These tests use Client(mcp) with the real memory:// Docket backend.
+These tests use Client(mcp, mode="legacy") with the real memory:// Docket backend.
"""
import asyncio
@@ -44,7 +44,7 @@ class TestElicitationRelay:
assert message == "What is your name?"
return ElicitResult(action="accept", content={"value": "Alice"})
- async with Client(mcp, elicitation_handler=handler) as client:
+ async with Client(mcp, mode="legacy", elicitation_handler=handler) as client:
task = await client.call_tool("ask_name", {}, task=True)
result = await task.result()
assert result.data == "Hello, Alice!"
@@ -65,7 +65,7 @@ class TestElicitationRelay:
async def handler(message, response_type, params, ctx):
return ElicitResult(action="decline")
- async with Client(mcp, elicitation_handler=handler) as client:
+ async with Client(mcp, mode="legacy", elicitation_handler=handler) as client:
task = await client.call_tool("optional_input", {}, task=True)
result = await task.result()
assert result.data == "User declined"
@@ -84,7 +84,7 @@ class TestElicitationRelay:
async def handler(message, response_type, params, ctx):
return ElicitResult(action="cancel")
- async with Client(mcp, elicitation_handler=handler) as client:
+ async with Client(mcp, mode="legacy", elicitation_handler=handler) as client:
task = await client.call_tool("cancellable", {}, task=True)
result = await task.result()
assert result.data == "Cancelled"
@@ -109,7 +109,7 @@ class TestElicitationRelay:
async def handler(message, response_type, params, ctx):
return ElicitResult(action="accept", content={"name": "Bob", "age": 30})
- async with Client(mcp, elicitation_handler=handler) as client:
+ async with Client(mcp, mode="legacy", elicitation_handler=handler) as client:
task = await client.call_tool("get_user", {}, task=True)
result = await task.result()
assert result.data == "Bob is 30"
@@ -135,7 +135,7 @@ class TestElicitationRelay:
action="accept", content={"host": "localhost", "port": 8080}
)
- async with Client(mcp, elicitation_handler=handler) as client:
+ async with Client(mcp, mode="legacy", elicitation_handler=handler) as client:
task = await client.call_tool("get_config", {}, task=True)
result = await task.result()
assert result.data == "localhost:8080"
@@ -166,7 +166,7 @@ class TestElicitationRelay:
assert message == "Last name?"
return ElicitResult(action="accept", content={"value": "Doe"})
- async with Client(mcp, elicitation_handler=handler) as client:
+ async with Client(mcp, mode="legacy", elicitation_handler=handler) as client:
task = await client.call_tool("two_questions", {}, task=True)
result = await task.result()
assert result.data == "Jane Doe"
@@ -185,7 +185,7 @@ class TestElicitationRelay:
return f"Got: {result.data}"
return "Other"
- async with Client(mcp) as client:
+ async with Client(mcp, mode="legacy") as client:
task = await client.call_tool("needs_input", {}, task=True)
result = await asyncio.wait_for(task.result(), timeout=15.0)
assert result.data == "Cancelled as expected"
diff --git a/tests/server/tasks/test_task_meta_parameter.py b/tests/server/tasks/test_task_meta_parameter.py
index e76930637..bea973aba 100644
--- a/tests/server/tasks/test_task_meta_parameter.py
+++ b/tests/server/tasks/test_task_meta_parameter.py
@@ -76,7 +76,7 @@ class TestTaskMetaParameter:
# call_tool enriches the task_meta before passing to _run
# We test this via the client integration path
- async with Client(server) as client:
+ async with Client(server, mode="legacy") as client:
result = await client.call_tool("auto_key_tool", {}, task=True)
# Should succeed because fn_key was auto-populated
from fastmcp.client.tasks import ToolTask
@@ -105,7 +105,7 @@ class TestTaskMetaTTL:
custom_ttl_ms = 30000 # 30 seconds
- async with Client(server) as client:
+ async with Client(server, mode="legacy") as client:
# Use client.call_tool with task=True and ttl
task = await client.call_tool("ttl_tool", {}, task=True, ttl=custom_ttl_ms)
@@ -125,7 +125,7 @@ class TestTaskMetaTTL:
async def default_ttl_tool() -> str:
return "done"
- async with Client(server) as client:
+ async with Client(server, mode="legacy") as client:
# Use client.call_tool with task=True, default ttl
task = await client.call_tool("default_ttl_tool", {}, task=True)
@@ -169,7 +169,7 @@ class TestTaskMetaMiddleware:
server.add_middleware(TrackingMiddleware(middleware_saw_request))
- async with Client(server) as client:
+ async with Client(server, mode="legacy") as client:
# Use client to trigger the middleware chain
task = await client.call_tool("middleware_test_tool", {}, task=True)
@@ -193,7 +193,7 @@ class TestTaskMetaClientIntegration:
async def client_test_tool(x: int) -> int:
return x * 2
- async with Client(server) as client:
+ async with Client(server, mode="legacy") as client:
# Client passes task=True, server receives as task_meta
task = await client.call_tool("client_test_tool", {"x": 5}, task=True)
@@ -214,7 +214,7 @@ class TestTaskMetaClientIntegration:
async def immediate_tool(x: int) -> int:
return x * 2
- async with Client(server) as client:
+ async with Client(server, mode="legacy") as client:
# No task=True, should execute synchronously
result = await client.call_tool("immediate_tool", {"x": 5})
@@ -231,7 +231,7 @@ class TestTaskMetaClientIntegration:
custom_ttl_ms = 60000 # 60 seconds
- async with Client(server) as client:
+ async with Client(server, mode="legacy") as client:
task = await client.call_tool(
"custom_ttl_tool", {}, task=True, ttl=custom_ttl_ms
)
@@ -265,7 +265,7 @@ class TestTaskMetaDirectServerCall:
# Should get CreateTaskResult since we're in server context
return f"Created task: {result.task.task_id}"
- async with Client(server) as client:
+ async with Client(server, mode="legacy") as client:
# Call outer_tool which internally calls inner_tool with task_meta
result = await client.call_tool("outer_tool", {"x": 5})
# The outer tool should have successfully created a background task
@@ -288,7 +288,7 @@ class TestTaskMetaDirectServerCall:
assert isinstance(first_content, mcp_types.TextContent)
return f"Got result: {first_content.text}"
- async with Client(server) as client:
+ async with Client(server, mode="legacy") as client:
result = await client.call_tool("outer_tool", {"x": 5})
assert "Got result: 10" in str(result)
@@ -308,7 +308,7 @@ class TestTaskMetaDirectServerCall:
)
return f"Task TTL: {result.task.ttl}"
- async with Client(server) as client:
+ async with Client(server, mode="legacy") as client:
result = await client.call_tool("outer_tool", {"x": 5})
# The inner tool task should have the custom TTL
assert "Task TTL: 45000" in str(result)
diff --git a/tests/server/tasks/test_task_metadata.py b/tests/server/tasks/test_task_metadata.py
index 0d8935d36..2bfdf4b13 100644
--- a/tests/server/tasks/test_task_metadata.py
+++ b/tests/server/tasks/test_task_metadata.py
@@ -25,7 +25,7 @@ async def metadata_server():
async def test_tasks_get_includes_related_task_metadata(metadata_server: FastMCP):
"""tasks/get response includes io.modelcontextprotocol/related-task in _meta."""
- async with Client(metadata_server) as client:
+ async with Client(metadata_server, mode="legacy") as client:
# Submit a task
task = await client.call_tool("test_tool", {"value": 5}, task=True)
task_id = task.task_id
@@ -41,7 +41,7 @@ async def test_tasks_get_includes_related_task_metadata(metadata_server: FastMCP
async def test_tasks_result_includes_related_task_metadata(metadata_server: FastMCP):
"""tasks/result response includes io.modelcontextprotocol/related-task in _meta."""
- async with Client(metadata_server) as client:
+ async with Client(metadata_server, mode="legacy") as client:
# Submit and complete a task
task = await client.call_tool("test_tool", {"value": 7}, task=True)
result = await task.result()
@@ -54,7 +54,7 @@ async def test_tasks_result_includes_related_task_metadata(metadata_server: Fast
async def test_tasks_list_includes_related_task_metadata(metadata_server: FastMCP):
"""tasks/list response includes io.modelcontextprotocol/related-task in _meta."""
- async with Client(metadata_server) as client:
+ async with Client(metadata_server, mode="legacy") as client:
# List tasks via client (which uses protocol properly)
result = await client.list_tasks()
diff --git a/tests/server/tasks/test_task_methods.py b/tests/server/tasks/test_task_methods.py
index 493bc9167..4e8832ef6 100644
--- a/tests/server/tasks/test_task_methods.py
+++ b/tests/server/tasks/test_task_methods.py
@@ -39,7 +39,7 @@ async def endpoint_server():
async def test_tasks_get_endpoint_returns_status(endpoint_server):
"""POST /tasks/get returns task status."""
- async with Client(endpoint_server) as client:
+ async with Client(endpoint_server, mode="legacy") as client:
# Submit a task
task = await client.call_tool("quick_tool", {"value": 21}, task=True)
@@ -58,7 +58,7 @@ async def test_tasks_get_endpoint_returns_status(endpoint_server):
async def test_tasks_get_endpoint_includes_poll_interval(endpoint_server):
"""Task status includes pollFrequency hint."""
- async with Client(endpoint_server) as client:
+ async with Client(endpoint_server, mode="legacy") as client:
task = await client.call_tool("quick_tool", {"value": 42}, task=True)
status = await task.status()
@@ -68,7 +68,7 @@ async def test_tasks_get_endpoint_includes_poll_interval(endpoint_server):
async def test_tasks_result_endpoint_returns_result_when_completed(endpoint_server):
"""POST /tasks/result returns the tool result when completed."""
- async with Client(endpoint_server) as client:
+ async with Client(endpoint_server, mode="legacy") as client:
task = await client.call_tool("quick_tool", {"value": 21}, task=True)
# Wait for completion and get result
@@ -86,7 +86,7 @@ async def test_tasks_result_endpoint_errors_if_not_completed(endpoint_server):
await completion_signal.wait()
return "done"
- async with Client(endpoint_server) as client:
+ async with Client(endpoint_server, mode="legacy") as client:
task = await client.call_tool("blocked_tool", task=True)
# Try to get result immediately (task still running)
@@ -99,7 +99,7 @@ async def test_tasks_result_endpoint_errors_if_not_completed(endpoint_server):
async def test_tasks_result_endpoint_errors_if_task_not_found(endpoint_server):
"""POST /tasks/result returns error for non-existent task."""
- async with Client(endpoint_server) as client:
+ async with Client(endpoint_server, mode="legacy") as client:
# Try to get result for non-existent task
with pytest.raises(Exception):
await client.get_task_result("non-existent-task-id")
@@ -107,7 +107,7 @@ async def test_tasks_result_endpoint_errors_if_task_not_found(endpoint_server):
async def test_tasks_result_endpoint_returns_error_for_failed_task(endpoint_server):
"""POST /tasks/result returns error information for failed tasks."""
- async with Client(endpoint_server) as client:
+ async with Client(endpoint_server, mode="legacy") as client:
task = await client.call_tool("error_tool", task=True)
# Wait for task to fail
@@ -126,7 +126,7 @@ async def test_tasks_result_endpoint_returns_error_for_failed_task(endpoint_serv
async def test_tasks_list_endpoint_session_isolation(endpoint_server):
"""list_tasks returns only tasks submitted by this client."""
# Since client tracks tasks locally, this tests client-side tracking
- async with Client(endpoint_server) as client:
+ async with Client(endpoint_server, mode="legacy") as client:
# Submit multiple tasks (server generates IDs)
tasks = []
for i in range(3):
@@ -147,7 +147,7 @@ async def test_tasks_list_endpoint_session_isolation(endpoint_server):
async def test_get_status_nonexistent_task_raises_error(endpoint_server):
"""Getting status for nonexistent task raises MCP error (per SEP-1686 SDK behavior)."""
- async with Client(endpoint_server) as client:
+ async with Client(endpoint_server, mode="legacy") as client:
# Try to get status for task that was never created
# Per SDK implementation: raises ValueError which becomes JSON-RPC error
with pytest.raises(MCPError, match="Task nonexistent-task-id not found"):
@@ -156,7 +156,7 @@ async def test_get_status_nonexistent_task_raises_error(endpoint_server):
async def test_task_cancellation_workflow(endpoint_server):
"""Task can be cancelled, transitioning to cancelled state."""
- async with Client(endpoint_server) as client:
+ async with Client(endpoint_server, mode="legacy") as client:
# Submit slow task
task = await client.call_tool("slow_tool", {}, task=True)
@@ -199,7 +199,7 @@ async def test_task_cancellation_interrupts_running_coroutine(endpoint_server):
was_interrupted.set()
raise
- async with Client(endpoint_server) as client:
+ async with Client(endpoint_server, mode="legacy") as client:
task = await client.call_tool("interruptible_tool", {}, task=True)
# Wait for the tool to actually start executing
diff --git a/tests/server/tasks/test_task_mount.py b/tests/server/tasks/test_task_mount.py
index 0401259c1..218ef1a47 100644
--- a/tests/server/tasks/test_task_mount.py
+++ b/tests/server/tasks/test_task_mount.py
@@ -109,7 +109,7 @@ class TestMountedToolTasks:
async def test_mounted_tool_task_returns_task_object(self, parent_server):
"""Mounted tool called with task=True returns a task object."""
- async with Client(parent_server) as client:
+ async with Client(parent_server, mode="legacy") as client:
# Tool name is prefixed: child_multiply
task = await client.call_tool("child_multiply", {"a": 6, "b": 7}, task=True)
@@ -120,7 +120,7 @@ class TestMountedToolTasks:
async def test_mounted_tool_task_executes_in_background(self, parent_server):
"""Mounted tool task executes in background."""
- async with Client(parent_server) as client:
+ async with Client(parent_server, mode="legacy") as client:
task = await client.call_tool("child_multiply", {"a": 3, "b": 4}, task=True)
# Should execute in background
@@ -130,7 +130,7 @@ class TestMountedToolTasks:
self, parent_server: FastMCP
):
"""Mounted tool task returns correct result."""
- async with Client(parent_server) as client:
+ async with Client(parent_server, mode="legacy") as client:
task = await client.call_tool("child_multiply", {"a": 8, "b": 9}, task=True)
result = await task.result()
@@ -138,7 +138,7 @@ class TestMountedToolTasks:
async def test_mounted_tool_task_status(self, parent_server):
"""Can poll task status for mounted tool."""
- async with Client(parent_server) as client:
+ async with Client(parent_server, mode="legacy") as client:
task = await client.call_tool(
"child_slow_child_tool", {"duration": 0.5}, task=True
)
@@ -157,7 +157,7 @@ class TestMountedToolTasks:
@pytest.mark.timeout(10)
async def test_mounted_tool_task_cancellation(self, parent_server):
"""Can cancel a mounted tool task."""
- async with Client(parent_server) as client:
+ async with Client(parent_server, mode="legacy") as client:
task = await client.call_tool(
"child_slow_child_tool", {"duration": 10.0}, task=True
)
@@ -174,7 +174,7 @@ class TestMountedToolTasks:
async def test_graceful_degradation_sync_mounted_tool(self, parent_server):
"""Sync-only mounted tool returns error with task=True."""
- async with Client(parent_server) as client:
+ async with Client(parent_server, mode="legacy") as client:
task = await client.call_tool(
"child_sync_child_tool",
{"message": "hello"},
@@ -190,7 +190,7 @@ class TestMountedToolTasks:
async def test_parent_and_mounted_tools_both_work(self, parent_server):
"""Both parent and mounted tools work as tasks."""
- async with Client(parent_server) as client:
+ async with Client(parent_server, mode="legacy") as client:
# Parent tool
parent_task = await client.call_tool("parent_tool", {"value": 5}, task=True)
# Mounted tool
@@ -212,7 +212,7 @@ class TestMountedToolTasksNoPrefix:
self, parent_server_no_prefix
):
"""Mounted tool without prefix works as task."""
- async with Client(parent_server_no_prefix) as client:
+ async with Client(parent_server_no_prefix, mode="legacy") as client:
# No prefix, so tool keeps original name
task = await client.call_tool("multiply", {"a": 5, "b": 6}, task=True)
@@ -227,7 +227,7 @@ class TestMountedPromptTasks:
async def test_mounted_prompt_task_returns_task_object(self, parent_server):
"""Mounted prompt called with task=True returns a task object."""
- async with Client(parent_server) as client:
+ async with Client(parent_server, mode="legacy") as client:
# Prompt name is prefixed: child_child_prompt
task = await client.get_prompt(
"child_child_prompt", {"topic": "FastMCP"}, task=True
@@ -245,7 +245,7 @@ class TestMountedPromptTasks:
)
async def test_mounted_prompt_task_executes_in_background(self, parent_server):
"""Mounted prompt task executes in background."""
- async with Client(parent_server) as client:
+ async with Client(parent_server, mode="legacy") as client:
task = await client.get_prompt(
"child_child_prompt", {"topic": "testing"}, task=True
)
@@ -256,7 +256,7 @@ class TestMountedPromptTasks:
self, parent_server: FastMCP
):
"""Mounted prompt task returns correct result."""
- async with Client(parent_server) as client:
+ async with Client(parent_server, mode="legacy") as client:
task = await client.get_prompt(
"child_child_prompt", {"topic": "MCP protocol"}, task=True
)
@@ -271,7 +271,7 @@ class TestMountedResourceTasks:
async def test_mounted_resource_task_returns_task_object(self, parent_server):
"""Mounted resource read with task=True returns a task object."""
- async with Client(parent_server) as client:
+ async with Client(parent_server, mode="legacy") as client:
# Resource URI is prefixed: child://child/data.txt
task = await client.read_resource("child://child/data.txt", task=True)
@@ -287,14 +287,14 @@ class TestMountedResourceTasks:
)
async def test_mounted_resource_task_executes_in_background(self, parent_server):
"""Mounted resource task executes in background."""
- async with Client(parent_server) as client:
+ async with Client(parent_server, mode="legacy") as client:
task = await client.read_resource("child://child/data.txt", task=True)
assert not task.returned_immediately
async def test_mounted_resource_task_returns_correct_result(self, parent_server):
"""Mounted resource task returns correct result."""
- async with Client(parent_server) as client:
+ async with Client(parent_server, mode="legacy") as client:
task = await client.read_resource("child://child/data.txt", task=True)
result = await task.result()
@@ -309,7 +309,7 @@ class TestMountedResourceTasks:
)
async def test_mounted_resource_template_task(self, parent_server):
"""Mounted resource template with task=True works."""
- async with Client(parent_server) as client:
+ async with Client(parent_server, mode="legacy") as client:
task = await client.read_resource("child://child/item/99.json", task=True)
assert not task.returned_immediately
@@ -335,7 +335,7 @@ class TestMountedTaskDependencies:
parent = FastMCP("dep-parent")
parent.mount(child, namespace="child")
- async with Client(parent) as client:
+ async with Client(parent, mode="legacy") as client:
task = await client.call_tool("child_tool_with_docket", {}, task=True)
result = await task.result()
@@ -356,7 +356,7 @@ class TestMountedTaskDependencies:
parent = FastMCP("server-dep-parent")
parent.mount(child, namespace="child")
- async with Client(parent) as client:
+ async with Client(parent, mode="legacy") as client:
task = await client.call_tool("child_tool_with_server", {}, task=True)
await task.result()
@@ -380,7 +380,7 @@ class TestMountedTaskServerContext:
parent = FastMCP("parent")
parent.mount(child, namespace="child")
- async with Client(parent) as client:
+ async with Client(parent, mode="legacy") as client:
task = await client.call_tool("child_whoami", {}, task=True)
result = await task.result()
@@ -403,7 +403,7 @@ class TestMountedTaskServerContext:
parent = FastMCP("parent")
parent.mount(child, namespace="child")
- async with Client(parent) as client:
+ async with Client(parent, mode="legacy") as client:
task = await client.call_tool("child_whoami_ctx", {}, task=True)
result = await task.result()
@@ -427,7 +427,7 @@ class TestMountedTaskServerContext:
parent = FastMCP("parent")
parent.mount(child, namespace="child")
- async with Client(parent) as client:
+ async with Client(parent, mode="legacy") as client:
task = await client.call_tool("child_gc_deep_whoami", {}, task=True)
result = await task.result()
@@ -456,7 +456,7 @@ class TestMultipleMounts:
parent.mount(child1, namespace="math1")
parent.mount(child2, namespace="math2")
- async with Client(parent) as client:
+ async with Client(parent, mode="legacy") as client:
task1 = await client.call_tool("math1_add", {"a": 10, "b": 5}, task=True)
task2 = await client.call_tool(
"math2_subtract", {"a": 10, "b": 5}, task=True
@@ -489,7 +489,7 @@ class TestMountedFunctionNameCollisions:
parent.mount(child1, namespace="c1")
parent.mount(child2, namespace="c2")
- async with Client(parent) as client:
+ async with Client(parent, mode="legacy") as client:
# Both should execute their own implementation
task1 = await client.call_tool("c1_process", {"value": 10}, task=True)
task2 = await client.call_tool("c2_process", {"value": 10}, task=True)
@@ -517,7 +517,7 @@ class TestMountedFunctionNameCollisions:
parent.mount(child1) # No prefix
parent.mount(child2) # No prefix - overwrites child1's "process"
- async with Client(parent) as client:
+ async with Client(parent, mode="legacy") as client:
# Last mount wins - child2's process should execute
task = await client.call_tool("process", {"value": 10}, task=True)
result = await task.result()
@@ -536,7 +536,7 @@ class TestMountedFunctionNameCollisions:
child.mount(grandchild, namespace="gc")
parent.mount(child, namespace="child")
- async with Client(parent) as client:
+ async with Client(parent, mode="legacy") as client:
# Tool should be accessible and execute correctly
task = await client.call_tool("child_gc_deep_tool", {}, task=True)
result = await task.result()
@@ -548,7 +548,7 @@ class TestMountedTaskList:
async def test_list_tasks_includes_mounted_tasks(self, parent_server):
"""Task list includes tasks from mounted server tools."""
- async with Client(parent_server) as client:
+ async with Client(parent_server, mode="legacy") as client:
# Create tasks on both parent and mounted tools
parent_task = await client.call_tool("parent_tool", {"value": 1}, task=True)
child_task = await client.call_tool(
@@ -645,13 +645,13 @@ class TestMountedTaskConfigModes:
async def test_optional_mode_sync_through_mount(self, parent_with_modes):
"""Optional mode tool works without task through mount."""
- async with Client(parent_with_modes) as client:
+ async with Client(parent_with_modes, mode="legacy") as client:
result = await client.call_tool("child_optional_tool", {})
assert "optional result" in str(result)
async def test_optional_mode_task_through_mount(self, parent_with_modes):
"""Optional mode tool works with task through mount."""
- async with Client(parent_with_modes) as client:
+ async with Client(parent_with_modes, mode="legacy") as client:
task = await client.call_tool("child_optional_tool", {}, task=True)
assert task is not None
result = await task.result()
@@ -659,7 +659,7 @@ class TestMountedTaskConfigModes:
async def test_required_mode_with_task_through_mount(self, parent_with_modes):
"""Required mode tool succeeds with task through mount."""
- async with Client(parent_with_modes) as client:
+ async with Client(parent_with_modes, mode="legacy") as client:
task = await client.call_tool("child_required_tool", {}, task=True)
assert task is not None
result = await task.result()
@@ -669,7 +669,7 @@ class TestMountedTaskConfigModes:
"""Required mode tool errors without task through mount."""
from fastmcp.exceptions import ToolError
- async with Client(parent_with_modes) as client:
+ async with Client(parent_with_modes, mode="legacy") as client:
with pytest.raises(ToolError) as exc_info:
await client.call_tool("child_required_tool", {})
@@ -677,13 +677,13 @@ class TestMountedTaskConfigModes:
async def test_forbidden_mode_sync_through_mount(self, parent_with_modes):
"""Forbidden mode tool works without task through mount."""
- async with Client(parent_with_modes) as client:
+ async with Client(parent_with_modes, mode="legacy") as client:
result = await client.call_tool("child_forbidden_tool", {})
assert "forbidden result" in str(result)
async def test_forbidden_mode_with_task_through_mount(self, parent_with_modes):
"""Forbidden mode tool degrades gracefully with task through mount."""
- async with Client(parent_with_modes) as client:
+ async with Client(parent_with_modes, mode="legacy") as client:
task = await client.call_tool(
"child_forbidden_tool", {}, task=True, raise_on_error=False
)
@@ -787,7 +787,7 @@ class TestMiddlewareWithMountedTasks:
parent.mount(child, namespace="c")
parent.add_middleware(ToolTracingMiddleware("parent", calls))
- async with Client(parent) as client:
+ async with Client(parent, mode="legacy") as client:
task = await client.call_tool("c_gc_compute", {"x": 5}, task=True)
result = await task.result()
assert result.data == 10
@@ -831,7 +831,7 @@ class TestMiddlewareWithMountedTasks:
parent.mount(child, namespace="c")
parent.add_middleware(ResourceTracingMiddleware("parent", calls))
- async with Client(parent) as client:
+ async with Client(parent, mode="legacy") as client:
task = await client.read_resource("data://c/gc/value", task=True)
result = await task.result()
assert result[0].text == "result"
@@ -874,7 +874,7 @@ class TestMiddlewareWithMountedTasks:
parent.mount(child, namespace="c")
parent.add_middleware(PromptTracingMiddleware("parent", calls))
- async with Client(parent) as client:
+ async with Client(parent, mode="legacy") as client:
task = await client.get_prompt("c_gc_greet", {"name": "World"}, task=True)
result = await task.result()
assert result.messages[0].content.text == "Hello, World!"
@@ -917,7 +917,7 @@ class TestMiddlewareWithMountedTasks:
parent.mount(child, namespace="c")
parent.add_middleware(ResourceTracingMiddleware("parent", calls))
- async with Client(parent) as client:
+ async with Client(parent, mode="legacy") as client:
task = await client.read_resource("item://c/gc/42", task=True)
result = await task.result()
assert result[0].text == "item-42"
@@ -966,7 +966,7 @@ class TestMountedTasksWithTaskMetaParameter:
)
return f"task:{result.task.task_id}"
- async with Client(parent) as client:
+ async with Client(parent, mode="legacy") as client:
result = await client.call_tool("outer", {})
assert "task:" in str(result)
@@ -990,7 +990,7 @@ class TestMountedTasksWithTaskMetaParameter:
)
return f"task:{result.task.task_id}"
- async with Client(parent) as client:
+ async with Client(parent, mode="legacy") as client:
result = await client.call_tool("outer", {})
assert "task:" in str(result)
@@ -1014,7 +1014,7 @@ class TestMountedTasksWithTaskMetaParameter:
)
return f"task:{result.task.task_id}"
- async with Client(parent) as client:
+ async with Client(parent, mode="legacy") as client:
result = await client.call_tool("outer", {})
assert "task:" in str(result)
@@ -1041,7 +1041,7 @@ class TestMountedTasksWithTaskMetaParameter:
)
return f"task:{result.task.task_id}"
- async with Client(parent) as client:
+ async with Client(parent, mode="legacy") as client:
result = await client.call_tool("outer", {})
assert "task:" in str(result)
@@ -1068,7 +1068,7 @@ class TestMountedTasksWithTaskMetaParameter:
)
return f"task:{result.task.task_id}"
- async with Client(parent) as client:
+ async with Client(parent, mode="legacy") as client:
result = await client.call_tool("outer", {})
assert "task:" in str(result)
@@ -1092,7 +1092,7 @@ class TestMountedTasksWithTaskMetaParameter:
)
return f"task:{result.task.task_id}"
- async with Client(parent) as client:
+ async with Client(parent, mode="legacy") as client:
result = await client.call_tool("outer", {})
assert "task:" in str(result)
@@ -1119,6 +1119,6 @@ class TestMountedTasksWithTaskMetaParameter:
)
return f"task:{result.task.task_id}"
- async with Client(parent) as client:
+ async with Client(parent, mode="legacy") as client:
result = await client.call_tool("outer", {})
assert "task:" in str(result)
diff --git a/tests/server/tasks/test_task_prompts.py b/tests/server/tasks/test_task_prompts.py
index ca62a3a0f..1054d3cee 100644
--- a/tests/server/tasks/test_task_prompts.py
+++ b/tests/server/tasks/test_task_prompts.py
@@ -31,7 +31,7 @@ async def prompt_server():
async def test_synchronous_prompt_unchanged(prompt_server):
"""Prompts without task metadata execute synchronously as before."""
- async with Client(prompt_server) as client:
+ async with Client(prompt_server, mode="legacy") as client:
# Regular call without task metadata
result = await client.get_prompt("simple_prompt", {"topic": "AI"})
@@ -41,7 +41,7 @@ async def test_synchronous_prompt_unchanged(prompt_server):
async def test_prompt_with_task_metadata_returns_immediately(prompt_server):
"""Prompts with task metadata return immediately with PromptTask object."""
- async with Client(prompt_server) as client:
+ async with Client(prompt_server, mode="legacy") as client:
# Call with task metadata
task = await client.get_prompt("background_prompt", {"topic": "AI"}, task=True)
@@ -59,7 +59,7 @@ async def test_prompt_with_task_metadata_returns_immediately(prompt_server):
)
async def test_prompt_task_executes_in_background(prompt_server):
"""Prompt task executes via Docket in background."""
- async with Client(prompt_server) as client:
+ async with Client(prompt_server, mode="legacy") as client:
task = await client.get_prompt(
"background_prompt",
{"topic": "Machine Learning", "depth": "comprehensive"},
@@ -89,7 +89,7 @@ async def test_forbidden_mode_prompt_rejects_task_calls(prompt_server):
async def sync_only_prompt(topic: str) -> str:
return f"Sync prompt: {topic}"
- async with Client(prompt_server) as client:
+ async with Client(prompt_server, mode="legacy") as client:
# Calling with task=True when task=False should raise MCPError
import pytest
diff --git a/tests/server/tasks/test_task_protocol.py b/tests/server/tasks/test_task_protocol.py
index 9fd2fd7a6..f47648618 100644
--- a/tests/server/tasks/test_task_protocol.py
+++ b/tests/server/tasks/test_task_protocol.py
@@ -31,7 +31,7 @@ async def task_enabled_server():
async def test_task_metadata_includes_task_id_and_ttl(task_enabled_server):
"""Task metadata properly includes server-generated taskId and ttl."""
- async with Client(task_enabled_server) as client:
+ async with Client(task_enabled_server, mode="legacy") as client:
# Submit with specific ttl (server generates task ID)
task = await client.call_tool(
"simple_tool",
@@ -54,7 +54,7 @@ async def test_task_notification_sent_after_submission(task_enabled_server):
async def background_tool(message: str) -> str:
return f"Processed: {message}"
- async with Client(task_enabled_server) as client:
+ async with Client(task_enabled_server, mode="legacy") as client:
task = await client.call_tool("background_tool", {"message": "test"}, task=True)
assert task
assert not task.returned_immediately
@@ -71,7 +71,7 @@ async def test_failed_task_stores_error(task_enabled_server):
async def failing_task_tool() -> str:
raise ValueError("This tool always fails")
- async with Client(task_enabled_server) as client:
+ async with Client(task_enabled_server, mode="legacy") as client:
task = await client.call_tool("failing_task_tool", task=True)
assert task
assert not task.returned_immediately
diff --git a/tests/server/tasks/test_task_proxy.py b/tests/server/tasks/test_task_proxy.py
index c272a4444..c8abd5c5d 100644
--- a/tests/server/tasks/test_task_proxy.py
+++ b/tests/server/tasks/test_task_proxy.py
@@ -68,13 +68,13 @@ class TestProxyToolsSyncExecution:
async def test_tool_sync_execution_works(self, proxy_server: FastMCP):
"""Tool called without task=True works through proxy."""
- async with Client(proxy_server) as client:
+ async with Client(proxy_server, mode="legacy") as client:
result = await client.call_tool("add_numbers", {"a": 5, "b": 3})
assert "8" in str(result)
async def test_sync_only_tool_works(self, proxy_server: FastMCP):
"""Sync-only tool works through proxy."""
- async with Client(proxy_server) as client:
+ async with Client(proxy_server, mode="legacy") as client:
result = await client.call_tool("sync_only_tool", {"message": "test"})
assert "sync: test" in str(result)
@@ -84,7 +84,7 @@ class TestProxyToolsTaskForbidden:
async def test_tool_task_returns_error_immediately(self, proxy_server: FastMCP):
"""Tool called with task=True through proxy returns error immediately."""
- async with Client(proxy_server) as client:
+ async with Client(proxy_server, mode="legacy") as client:
task = await client.call_tool(
"add_numbers", {"a": 5, "b": 3}, task=True, raise_on_error=False
)
@@ -100,7 +100,7 @@ class TestProxyToolsTaskForbidden:
self, proxy_server: FastMCP
):
"""Sync-only tool with task=True also returns error immediately."""
- async with Client(proxy_server) as client:
+ async with Client(proxy_server, mode="legacy") as client:
task = await client.call_tool(
"sync_only_tool",
{"message": "test"},
@@ -118,7 +118,7 @@ class TestProxyPromptsSyncExecution:
async def test_prompt_sync_execution_works(self, proxy_server: FastMCP):
"""Prompt called without task=True works through proxy."""
- async with Client(proxy_server) as client:
+ async with Client(proxy_server, mode="legacy") as client:
result = await client.get_prompt("greeting_prompt", {"name": "Alice"})
assert isinstance(result.messages[0].content, TextContent)
assert "Hello, Alice!" in result.messages[0].content.text
@@ -135,7 +135,7 @@ class TestProxyPromptsTaskForbidden:
)
async def test_prompt_task_raises_mcp_error(self, proxy_server: FastMCP):
"""Prompt called with task=True through proxy raises MCPError."""
- async with Client(proxy_server) as client:
+ async with Client(proxy_server, mode="legacy") as client:
with pytest.raises(MCPError) as exc_info:
await client.get_prompt("greeting_prompt", {"name": "Alice"}, task=True)
@@ -147,14 +147,14 @@ class TestProxyResourcesSyncExecution:
async def test_resource_sync_execution_works(self, proxy_server: FastMCP):
"""Resource read without task=True works through proxy."""
- async with Client(proxy_server) as client:
+ async with Client(proxy_server, mode="legacy") as client:
result = await client.read_resource("data://info.txt")
assert isinstance(result[0], TextResourceContents)
assert "Important information from the backend" in result[0].text
async def test_resource_template_sync_execution_works(self, proxy_server: FastMCP):
"""Resource template without task=True works through proxy."""
- async with Client(proxy_server) as client:
+ async with Client(proxy_server, mode="legacy") as client:
result = await client.read_resource("data://user/42.json")
assert isinstance(result[0], TextResourceContents)
assert '"id": "42"' in result[0].text
@@ -171,7 +171,7 @@ class TestProxyResourcesTaskForbidden:
)
async def test_resource_task_raises_mcp_error(self, proxy_server: FastMCP):
"""Resource read with task=True through proxy raises MCPError."""
- async with Client(proxy_server) as client:
+ async with Client(proxy_server, mode="legacy") as client:
with pytest.raises(MCPError) as exc_info:
await client.read_resource("data://info.txt", task=True)
@@ -185,7 +185,7 @@ class TestProxyResourcesTaskForbidden:
)
async def test_resource_template_task_raises_mcp_error(self, proxy_server: FastMCP):
"""Resource template with task=True through proxy raises MCPError."""
- async with Client(proxy_server) as client:
+ async with Client(proxy_server, mode="legacy") as client:
with pytest.raises(MCPError) as exc_info:
await client.read_resource("data://user/42.json", task=True)
diff --git a/tests/server/tasks/test_task_resources.py b/tests/server/tasks/test_task_resources.py
index f7768adc7..acb136281 100644
--- a/tests/server/tasks/test_task_resources.py
+++ b/tests/server/tasks/test_task_resources.py
@@ -36,7 +36,7 @@ async def resource_server():
async def test_synchronous_resource_unchanged(resource_server):
"""Resources without task metadata execute synchronously as before."""
- async with Client(resource_server) as client:
+ async with Client(resource_server, mode="legacy") as client:
# Regular call without task metadata
result = await client.read_resource("file://data.txt")
@@ -46,7 +46,7 @@ async def test_synchronous_resource_unchanged(resource_server):
async def test_resource_with_task_metadata_returns_immediately(resource_server):
"""Resources with task metadata return immediately with ResourceTask object."""
- async with Client(resource_server) as client:
+ async with Client(resource_server, mode="legacy") as client:
# Call with task metadata
task = await client.read_resource("file://large.txt", task=True)
@@ -64,7 +64,7 @@ async def test_resource_with_task_metadata_returns_immediately(resource_server):
)
async def test_resource_task_executes_in_background(resource_server):
"""Resource task executes via Docket in background."""
- async with Client(resource_server) as client:
+ async with Client(resource_server, mode="legacy") as client:
task = await client.read_resource("file://large.txt", task=True)
# Verify background execution
@@ -84,7 +84,7 @@ async def test_resource_task_executes_in_background(resource_server):
)
async def test_resource_template_with_task(resource_server):
"""Resource templates with task=True execute in background."""
- async with Client(resource_server) as client:
+ async with Client(resource_server, mode="legacy") as client:
task = await client.read_resource("file://user/123/data.json", task=True)
# Verify background execution
@@ -113,7 +113,7 @@ async def test_forbidden_mode_resource_rejects_task_calls(resource_server):
async def sync_only_resource() -> str:
return "Sync content"
- async with Client(resource_server) as client:
+ async with Client(resource_server, mode="legacy") as client:
# Calling with task=True when task=False should raise MCPError
with pytest.raises(MCPError) as exc_info:
await client.read_resource("file://sync.txt", task=True)
diff --git a/tests/server/tasks/test_task_return_types.py b/tests/server/tasks/test_task_return_types.py
index 15452c79f..a8255a5a7 100644
--- a/tests/server/tasks/test_task_return_types.py
+++ b/tests/server/tasks/test_task_return_types.py
@@ -96,7 +96,7 @@ async def test_task_basic_types(
expected_value: Any,
):
"""Task mode returns basic types correctly."""
- async with Client(return_type_server) as client:
+ async with Client(return_type_server, mode="legacy") as client:
task = await client.call_tool(tool_name, task=True)
result = await task
assert isinstance(result.data, expected_type)
@@ -105,7 +105,7 @@ async def test_task_basic_types(
async def test_task_model_return(return_type_server):
"""Task mode returns same BaseModel (as dict) as immediate mode."""
- async with Client(return_type_server) as client:
+ async with Client(return_type_server, mode="legacy") as client:
task = await client.call_tool("return_model", task=True)
result = await task
@@ -118,7 +118,7 @@ async def test_task_model_return(return_type_server):
async def test_task_vs_immediate_equivalence(return_type_server):
"""Verify task mode and immediate mode return identical results."""
- async with Client(return_type_server) as client:
+ async with Client(return_type_server, mode="legacy") as client:
# Test a few types to verify equivalence
tools_to_test = ["return_string", "return_int", "return_dict"]
@@ -160,7 +160,7 @@ async def prompt_return_server():
async def test_prompt_task_single_message(prompt_return_server):
"""Prompt task returns single message correctly."""
- async with Client(prompt_return_server) as client:
+ async with Client(prompt_return_server, mode="legacy") as client:
task = await client.get_prompt("single_message_prompt", task=True)
result = await task
@@ -170,7 +170,7 @@ async def test_prompt_task_single_message(prompt_return_server):
async def test_prompt_task_multiple_messages(prompt_return_server):
"""Prompt task returns multiple messages correctly."""
- async with Client(prompt_return_server) as client:
+ async with Client(prompt_return_server, mode="legacy") as client:
task = await client.get_prompt("multi_message_prompt", task=True)
result = await task
@@ -202,7 +202,7 @@ async def resource_return_server():
async def test_resource_task_text_content(resource_return_server):
"""Resource task returns text content correctly."""
- async with Client(resource_return_server) as client:
+ async with Client(resource_return_server, mode="legacy") as client:
task = await client.read_resource("text://simple", task=True)
contents = await task
@@ -212,7 +212,7 @@ async def test_resource_task_text_content(resource_return_server):
async def test_resource_task_json_content(resource_return_server):
"""Resource task returns structured content correctly."""
- async with Client(resource_return_server) as client:
+ async with Client(resource_return_server, mode="legacy") as client:
task = await client.read_resource("data://json", task=True)
contents = await task
@@ -287,7 +287,7 @@ async def test_task_binary_types(
assertion_fn: Any,
):
"""Task mode handles binary and special types."""
- async with Client(binary_type_server) as client:
+ async with Client(binary_type_server, mode="legacy") as client:
task = await client.call_tool(tool_name, task=True)
result = await task
assert isinstance(result.data, expected_type)
@@ -338,7 +338,7 @@ async def test_task_collection_types(
expected_value: Any,
):
"""Task mode handles collection types."""
- async with Client(collection_server) as client:
+ async with Client(collection_server, mode="legacy") as client:
task = await client.call_tool(tool_name, task=True)
result = await task
assert isinstance(result.data, expected_type)
@@ -347,7 +347,7 @@ async def test_task_collection_types(
async def test_task_empty_dict_return(collection_server):
"""Task mode handles empty dict return."""
- async with Client(collection_server) as client:
+ async with Client(collection_server, mode="legacy") as client:
task = await client.call_tool("return_empty_dict", task=True)
result = await task
# Empty structured content becomes None in data
@@ -426,7 +426,7 @@ async def test_task_media_types(
assertion_fn: Any,
):
"""Task mode handles media types (Image, Audio, File)."""
- async with Client(media_server) as client:
+ async with Client(media_server, mode="legacy") as client:
task = await client.call_tool(tool_name, task=True)
result = await task
assert assertion_fn(result)
@@ -498,7 +498,7 @@ async def test_task_structured_dict_types(
expected_age: int,
):
"""Task mode handles TypedDict and dataclass returns."""
- async with Client(structured_type_server) as client:
+ async with Client(structured_type_server, mode="legacy") as client:
task = await client.call_tool(tool_name, task=True)
result = await task
# Both deserialize to dynamic Root class
@@ -520,7 +520,7 @@ async def test_task_union_types(
expected_value: Any,
):
"""Task mode handles union type branches."""
- async with Client(structured_type_server) as client:
+ async with Client(structured_type_server, mode="legacy") as client:
task = await client.call_tool(tool_name, task=True)
result = await task
assert isinstance(result.data, expected_type)
@@ -541,7 +541,7 @@ async def test_task_optional_types(
expected_value: Any,
):
"""Task mode handles Optional types."""
- async with Client(structured_type_server) as client:
+ async with Client(structured_type_server, mode="legacy") as client:
task = await client.call_tool(tool_name, task=True)
result = await task
assert isinstance(result.data, expected_type)
@@ -650,7 +650,7 @@ async def test_task_mcp_content_types(
assertion_fn: Any,
):
"""Task mode handles MCP content block types."""
- async with Client(mcp_content_server) as client:
+ async with Client(mcp_content_server, mode="legacy") as client:
task = await client.call_tool(tool_name, task=True)
result = await task
assert assertion_fn(result)
@@ -658,7 +658,7 @@ async def test_task_mcp_content_types(
async def test_task_mixed_content_return(mcp_content_server):
"""Task mode handles mixed content list return."""
- async with Client(mcp_content_server) as client:
+ async with Client(mcp_content_server, mode="legacy") as client:
task = await client.call_tool("return_mixed_content", task=True)
result = await task
assert len(result.content) == 3
diff --git a/tests/server/tasks/test_task_security.py b/tests/server/tasks/test_task_security.py
index 5d3b16ffa..605382894 100644
--- a/tests/server/tasks/test_task_security.py
+++ b/tests/server/tasks/test_task_security.py
@@ -38,7 +38,7 @@ async def test_same_client_can_access_all_its_tasks(task_server: FastMCP):
)
reset = auth_context_var.set(AuthenticatedUser(token))
try:
- async with Client(task_server) as client:
+ async with Client(task_server, mode="legacy") as client:
task1 = await client.call_tool(
"secret_tool", {"data": "first"}, task=True, task_id="task-1"
)
@@ -60,7 +60,7 @@ async def test_same_client_can_access_all_its_tasks(task_server: FastMCP):
async def test_unauthenticated_client_can_access_its_tasks(task_server: FastMCP):
"""An unauthenticated client can access tasks it created (by task ID)."""
- async with Client(task_server) as client:
+ async with Client(task_server, mode="legacy") as client:
task = await client.call_tool(
"secret_tool", {"data": "hello"}, task=True, task_id="my-task"
)
@@ -95,14 +95,14 @@ async def test_distinct_clients_cannot_access_each_others_tasks(
a peer's task id returns 'not found'."""
reset = _set_auth("client-a")
try:
- async with Client(task_server) as client_a:
+ async with Client(task_server, mode="legacy") as client_a:
task_id = await _submit_task_id(client_a, "client-a-secret")
finally:
auth_context_var.reset(reset)
reset = _set_auth("client-b")
try:
- async with Client(task_server) as client_b:
+ async with Client(task_server, mode="legacy") as client_b:
with pytest.raises(Exception, match="not found"):
await client_b.get_task_status(task_id)
finally:
@@ -118,14 +118,14 @@ async def test_distinct_subs_same_client_id_cannot_access_each_others_tasks(
reset = _set_auth(shared_client, sub="user-alice")
try:
- async with Client(task_server) as alice:
+ async with Client(task_server, mode="legacy") as alice:
task_id = await _submit_task_id(alice, "alice-secret")
finally:
auth_context_var.reset(reset)
reset = _set_auth(shared_client, sub="user-bob")
try:
- async with Client(task_server) as bob:
+ async with Client(task_server, mode="legacy") as bob:
with pytest.raises(Exception, match="not found"):
await bob.get_task_status(task_id)
finally:
@@ -139,11 +139,11 @@ async def test_authenticated_and_anonymous_keyspaces_are_disjoint(
tasks (and vice versa) even when colliding on task id."""
reset = _set_auth("client-a")
try:
- async with Client(task_server) as authed:
+ async with Client(task_server, mode="legacy") as authed:
authed_task_id = await _submit_task_id(authed, "authed-secret")
finally:
auth_context_var.reset(reset)
- async with Client(task_server) as anon:
+ async with Client(task_server, mode="legacy") as anon:
with pytest.raises(Exception, match="not found"):
await anon.get_task_status(authed_task_id)
diff --git a/tests/server/tasks/test_task_status_notifications.py b/tests/server/tasks/test_task_status_notifications.py
index 98d333ca9..1b292bc73 100644
--- a/tests/server/tasks/test_task_status_notifications.py
+++ b/tests/server/tasks/test_task_status_notifications.py
@@ -55,7 +55,7 @@ async def notification_server():
async def test_subscription_spawned_for_tool_task(notification_server: FastMCP):
"""Subscription task is spawned when tool task is created."""
- async with Client(notification_server) as client:
+ async with Client(notification_server, mode="legacy") as client:
# Create task - should spawn subscription
task = await client.call_tool("quick_task", {"value": 5}, task=True)
@@ -69,7 +69,7 @@ async def test_subscription_spawned_for_tool_task(notification_server: FastMCP):
async def test_subscription_handles_task_completion(notification_server: FastMCP):
"""Subscription properly handles task completion and cleanup."""
- async with Client(notification_server) as client:
+ async with Client(notification_server, mode="legacy") as client:
# Multiple tasks should each get their own subscription
task1 = await client.call_tool("quick_task", {"value": 1}, task=True)
task2 = await client.call_tool("quick_task", {"value": 2}, task=True)
@@ -91,7 +91,7 @@ async def test_subscription_handles_task_completion(notification_server: FastMCP
async def test_subscription_handles_task_failure(notification_server: FastMCP):
"""Subscription properly handles task failure."""
- async with Client(notification_server) as client:
+ async with Client(notification_server, mode="legacy") as client:
task = await client.call_tool("failing_task", {}, task=True)
# Task should fail
@@ -104,7 +104,7 @@ async def test_subscription_handles_task_failure(notification_server: FastMCP):
async def test_subscription_for_prompt_tasks(notification_server: FastMCP):
"""Subscriptions work for prompt tasks."""
- async with Client(notification_server) as client:
+ async with Client(notification_server, mode="legacy") as client:
task = await client.get_prompt("test_prompt", {"name": "World"}, task=True)
result = await task
@@ -117,7 +117,7 @@ async def test_subscription_for_prompt_tasks(notification_server: FastMCP):
async def test_subscription_for_resource_tasks(notification_server: FastMCP):
"""Subscriptions work for resource tasks."""
- async with Client(notification_server) as client:
+ async with Client(notification_server, mode="legacy") as client:
task = await client.read_resource("test://resource", task=True)
result = await task
@@ -132,7 +132,7 @@ async def test_subscriptions_cleanup_on_session_disconnect(
):
"""Subscriptions are cleaned up when session disconnects."""
# Start session and create task
- async with Client(notification_server) as client:
+ async with Client(notification_server, mode="legacy") as client:
task = await client.call_tool("slow_task", {"duration": 1.0}, task=True)
task_id = task.task_id
# Disconnect before task completes (session __aexit__ cancels subscriptions)
@@ -145,7 +145,7 @@ async def test_subscriptions_cleanup_on_session_disconnect(
async def test_multiple_concurrent_subscriptions(notification_server: FastMCP):
"""Multiple concurrent tasks each have their own subscription."""
- async with Client(notification_server) as client:
+ async with Client(notification_server, mode="legacy") as client:
# Start many tasks concurrently
tasks = []
for i in range(10):
diff --git a/tests/server/tasks/test_task_tools.py b/tests/server/tasks/test_task_tools.py
index 1515504d1..c9d269bf7 100644
--- a/tests/server/tasks/test_task_tools.py
+++ b/tests/server/tasks/test_task_tools.py
@@ -59,7 +59,7 @@ async def test_task_tool_validates_model_arguments():
arguments = {"item": {"value": "a"}, "items": [{"value": "b"}]}
expected = {"item": "_Item", "element": "_Item"}
- async with Client(mcp) as client:
+ async with Client(mcp, mode="legacy") as client:
sync_result = await client.call_tool("inspect_items", arguments)
task = await client.call_tool("inspect_items", arguments, task=True)
task_result = await task.result()
@@ -92,7 +92,7 @@ async def test_task_tool_invalid_arguments_fail_before_task_state():
return item.value
recorder = _Recorder()
- async with Client(server, message_handler=recorder) as client:
+ async with Client(server, mode="legacy", message_handler=recorder) as client:
# `item` is missing its required `value` field.
task = await client.call_tool("needs_item", {"item": {}}, task=True)
assert task.returned_immediately
@@ -126,7 +126,7 @@ async def test_task_submission_honors_strict_input_validation():
return n * n
recorder = _Recorder()
- async with Client(server, message_handler=recorder) as client:
+ async with Client(server, mode="legacy", message_handler=recorder) as client:
# Sync path rejects the string-for-int coercion under strict validation.
with pytest.raises(ToolError):
await client.call_tool("square", {"n": "1"})
@@ -149,7 +149,7 @@ async def test_task_submission_valid_argument_under_strict_validation():
async def square(n: int) -> int:
return n * n
- async with Client(server) as client:
+ async with Client(server, mode="legacy") as client:
task = await client.call_tool("square", {"n": 4}, task=True)
assert not task.returned_immediately
result = await task.result()
@@ -174,7 +174,7 @@ def test_resolve_param_hints_handles_partials():
async def test_synchronous_tool_call_unchanged(tool_server):
"""Tools without task metadata execute synchronously as before."""
- async with Client(tool_server) as client:
+ async with Client(tool_server, mode="legacy") as client:
# Regular call without task metadata
result = await client.call_tool("simple_tool", {"message": "hello"})
@@ -184,7 +184,7 @@ async def test_synchronous_tool_call_unchanged(tool_server):
async def test_tool_with_task_metadata_returns_immediately(tool_server):
"""Tools with task metadata return immediately with ToolTask object."""
- async with Client(tool_server) as client:
+ async with Client(tool_server, mode="legacy") as client:
# Call with task metadata
task = await client.call_tool("simple_tool", {"message": "test"}, task=True)
assert task
@@ -207,7 +207,7 @@ async def test_tool_task_executes_in_background(tool_server):
await execution_completed.wait()
return "completed"
- async with Client(tool_server) as client:
+ async with Client(tool_server, mode="legacy") as client:
task = await client.call_tool("coordinated_tool", task=True)
assert task
assert not task.returned_immediately
@@ -229,7 +229,7 @@ async def test_tool_task_executes_in_background(tool_server):
async def test_forbidden_mode_tool_rejects_task_calls(tool_server):
"""Tools with task=False (mode=forbidden) reject task-augmented calls."""
- async with Client(tool_server) as client:
+ async with Client(tool_server, mode="legacy") as client:
# Calling with task=True when task=False should return error
task = await client.call_tool(
"sync_only_tool", {"message": "test"}, task=True, raise_on_error=False
diff --git a/tests/server/tasks/test_task_ttl.py b/tests/server/tasks/test_task_ttl.py
index 769fafffa..5760e4f99 100644
--- a/tests/server/tasks/test_task_ttl.py
+++ b/tests/server/tasks/test_task_ttl.py
@@ -32,7 +32,7 @@ async def keepalive_server():
async def test_keepalive_returned_in_submitted_state(keepalive_server: FastMCP):
"""ttl is returned in tasks/get even when task is submitted/working."""
- async with Client(keepalive_server) as client:
+ async with Client(keepalive_server, mode="legacy") as client:
# Submit task with explicit ttl
task = await client.call_tool(
"slow_task",
@@ -55,7 +55,7 @@ async def test_keepalive_returned_in_submitted_state(keepalive_server: FastMCP):
async def test_keepalive_returned_in_completed_state(keepalive_server: FastMCP):
"""ttl is returned in tasks/get after task completes."""
- async with Client(keepalive_server) as client:
+ async with Client(keepalive_server, mode="legacy") as client:
# Submit and complete task
task = await client.call_tool(
"quick_task",
@@ -77,7 +77,7 @@ async def test_keepalive_returned_in_completed_state(keepalive_server: FastMCP):
async def test_default_keepalive_when_not_specified(keepalive_server: FastMCP):
"""Default ttl is used when client doesn't specify."""
- async with Client(keepalive_server) as client:
+ async with Client(keepalive_server, mode="legacy") as client:
# Submit without explicit ttl
task = await client.call_tool("quick_task", {"value": 3}, task=True)
await task.wait(timeout=2.0)
diff --git a/tests/server/telemetry/test_sampling_tracing.py b/tests/server/telemetry/test_sampling_tracing.py
index 53e166cf3..59cb7afb7 100644
--- a/tests/server/telemetry/test_sampling_tracing.py
+++ b/tests/server/telemetry/test_sampling_tracing.py
@@ -181,7 +181,9 @@ class TestSamplingCreateMessageSpan:
result = await context.sample(messages=question)
return result.text or ""
- async with Client(mcp, sampling_handler=sampling_handler) as client:
+ async with Client(
+ mcp, mode="legacy", sampling_handler=sampling_handler
+ ) as client:
await client.call_tool("ask", {"question": "hi"})
spans = _spans_named(trace_exporter, "sampling create_message")
@@ -220,7 +222,9 @@ class TestSamplingCreateMessageSpan:
return result.text or ""
with pytest.raises(Exception):
- async with Client(mcp, sampling_handler=sampling_handler) as client:
+ async with Client(
+ mcp, mode="legacy", sampling_handler=sampling_handler
+ ) as client:
await client.call_tool("ask", {"question": "hi"})
spans = _spans_named(trace_exporter, "sampling create_message")
@@ -339,7 +343,9 @@ class TestAttributesSurviveANonForwardingSampler:
result = await context.sample(messages=question)
return result.text or ""
- async with Client(mcp, sampling_handler=sampling_handler) as client:
+ async with Client(
+ mcp, mode="legacy", sampling_handler=sampling_handler
+ ) as client:
await client.call_tool("ask", {"question": "hi"})
spans = _spans_named(trace_exporter, "sampling create_message")
@@ -465,7 +471,9 @@ class TestAttributeRestoreRespectsSampler:
result = await context.sample(messages=question)
return result.text or ""
- async with Client(mcp, sampling_handler=sampling_handler) as client:
+ async with Client(
+ mcp, mode="legacy", sampling_handler=sampling_handler
+ ) as client:
await client.call_tool("ask", {"question": "hi"})
spans = _spans_named(trace_exporter, "sampling create_message")
@@ -496,7 +504,9 @@ class TestAttributeRestoreRespectsSampler:
result = await context.sample(messages=question)
return result.text or ""
- async with Client(mcp, sampling_handler=sampling_handler) as client:
+ async with Client(
+ mcp, mode="legacy", sampling_handler=sampling_handler
+ ) as client:
await client.call_tool("ask", {"question": "hi"})
spans = _spans_named(trace_exporter, "sampling create_message")
@@ -647,7 +657,9 @@ class TestAttributeRestoreRespectsSampler:
result = await context.sample(messages=question)
return result.text or ""
- async with Client(mcp, sampling_handler=sampling_handler) as client:
+ async with Client(
+ mcp, mode="legacy", sampling_handler=sampling_handler
+ ) as client:
await client.call_tool("ask", {"question": "hi"})
spans = _spans_named(trace_exporter, "sampling create_message")
@@ -758,7 +770,9 @@ class TestRestoreDoesNotChurnAttributeLimitEvictions:
result = await context.sample(messages=question)
return result.text or ""
- async with Client(mcp, sampling_handler=sampling_handler) as client:
+ async with Client(
+ mcp, mode="legacy", sampling_handler=sampling_handler
+ ) as client:
await client.call_tool("ask", {"question": "hi"})
spans = _spans_named(exporter, "sampling create_message")
diff --git a/tests/server/telemetry/test_server_tracing.py b/tests/server/telemetry/test_server_tracing.py
index 3c6594aa5..1b90f7e1c 100644
--- a/tests/server/telemetry/test_server_tracing.py
+++ b/tests/server/telemetry/test_server_tracing.py
@@ -449,7 +449,7 @@ class TestSeamServerSpan:
):
mcp = FastMCP("test-server")
- async with Client(mcp) as client:
+ async with Client(mcp, mode="legacy") as client:
await client.set_logging_level("info")
spans = trace_exporter.get_finished_spans()
@@ -470,7 +470,7 @@ class TestSeamServerSpan:
"""A seam-spanned method must produce exactly one SERVER span, not two."""
mcp = FastMCP("test-server")
- async with Client(mcp) as client:
+ async with Client(mcp, mode="legacy") as client:
await client.set_logging_level("info")
spans = trace_exporter.get_finished_spans()
@@ -745,10 +745,14 @@ class TestProtocolVersionAttribute:
self, trace_exporter: InMemorySpanExporter
):
"""Seam-only methods (never reaching the high-level path) also carry the
- protocol version."""
+ protocol version.
+
+ Pinned to legacy: `logging/setLevel` is a handshake-era seam method the
+ modern (2026-07-28) protocol drops, so the span exists only on legacy.
+ """
mcp = FastMCP("test-server")
- async with Client(mcp) as client:
+ async with Client(mcp, mode="legacy") as client:
await client.set_logging_level("info")
spans = trace_exporter.get_finished_spans()
diff --git a/tests/server/test_icons.py b/tests/server/test_icons.py
index 2ee675307..b156d24fb 100644
--- a/tests/server/test_icons.py
+++ b/tests/server/test_icons.py
@@ -36,7 +36,7 @@ class TestServerIcons:
)
# Verify that icons and website_url are passed to the underlying server
- async with Client(mcp) as client:
+ async with Client(mcp, mode="legacy") as client:
server_info = client.initialize_result.server_info
assert server_info.website_url == "https://example.com"
assert server_info.icons == icons
@@ -45,7 +45,7 @@ class TestServerIcons:
"""Test that server works without icons and websiteUrl."""
mcp = FastMCP(name="TestServer")
- async with Client(mcp) as client:
+ async with Client(mcp, mode="legacy") as client:
server_info = client.initialize_result.server_info
assert server_info.website_url is None
assert server_info.icons is None
@@ -290,7 +290,7 @@ class TestIconTypes:
mcp = FastMCP("TestServer", icons=icons)
- async with Client(mcp) as client:
+ async with Client(mcp, mode="legacy") as client:
server_info = client.initialize_result.server_info
assert len(server_info.icons) == 3
assert server_info.icons == icons
@@ -319,7 +319,7 @@ class TestIconTypes:
mcp = FastMCP("TestServer", icons=icons)
- async with Client(mcp) as client:
+ async with Client(mcp, mode="legacy") as client:
server_info = client.initialize_result.server_info
assert server_info.icons[0].src == "https://example.com/icon.png"
assert server_info.icons[0].mime_type is None
@@ -336,7 +336,7 @@ class TestIconTheme:
mcp = FastMCP("TestServer", icons=icons)
- async with Client(mcp) as client:
+ async with Client(mcp, mode="legacy") as client:
server_info = client.initialize_result.server_info
assert server_info.icons[0].theme == theme
@@ -346,7 +346,7 @@ class TestIconTheme:
mcp = FastMCP("TestServer", icons=icons)
- async with Client(mcp) as client:
+ async with Client(mcp, mode="legacy") as client:
server_info = client.initialize_result.server_info
assert server_info.icons[0].theme is None
diff --git a/tests/server/test_mrtr_guards.py b/tests/server/test_mrtr_guards.py
index e574b20ca..e0e015551 100644
--- a/tests/server/test_mrtr_guards.py
+++ b/tests/server/test_mrtr_guards.py
@@ -992,7 +992,10 @@ class TestTaskExecution:
request_state=None,
)
- async with Client(mcp) as client:
+ # Client-side background-task submission (`task=True`) is the handshake-era
+ # SEP-1686 model; in 2026-07-28 tasks moved to a separate extension, so pin
+ # the era the "reject a guard's input-required from within a task" rule lives in.
+ async with Client(mcp, mode="legacy") as client:
task = await client.call_tool("book_flight", {}, task=True)
with pytest.raises(MCPError, match="background task"):
await task.result()
diff --git a/tests/server/test_protocol_eras.py b/tests/server/test_protocol_eras.py
index ca9a8ac41..d248c69ed 100644
--- a/tests/server/test_protocol_eras.py
+++ b/tests/server/test_protocol_eras.py
@@ -567,7 +567,7 @@ async def test_task_submission_and_get_on_legacy_latest(task_server):
`task=` parameter (verified: mcp.client.session.ClientSession.call_tool
exposes no task metadata arg) — see item below.
"""
- async with FastMCPClient(task_server) as client:
+ async with FastMCPClient(task_server, mode="legacy") as client:
assert client.initialize_result is not None
assert client.initialize_result.protocol_version == "2025-11-25"
diff --git a/tests/server/test_session_visibility.py b/tests/server/test_session_visibility.py
index d98a0d72f..6a6f9b105 100644
--- a/tests/server/test_session_visibility.py
+++ b/tests/server/test_session_visibility.py
@@ -70,7 +70,7 @@ class TestSessionVisibility:
assert rules[0]["tags"] == ["finance"]
return "activated"
- async with Client(mcp) as client:
+ async with Client(mcp, mode="legacy") as client:
result = await client.call_tool("activate_finance", {})
assert result.data == "activated"
@@ -94,7 +94,7 @@ class TestSessionVisibility:
assert rules[0]["tags"] == ["internal"]
return "deactivated"
- async with Client(mcp) as client:
+ async with Client(mcp, mode="legacy") as client:
result = await client.call_tool("deactivate_internal", {})
assert result.data == "deactivated"
@@ -116,7 +116,7 @@ class TestSessionVisibility:
# Globally disable finance tools
mcp.disable(tags={"finance"})
- async with Client(mcp) as client:
+ async with Client(mcp, mode="legacy") as client:
# Before activation, finance tool should not be visible
tools_before = await client.list_tools()
assert not any(t.name == "finance_tool" for t in tools_before)
@@ -151,7 +151,7 @@ class TestSessionVisibility:
# Globally disable finance tools
mcp.disable(tags={"finance"})
- async with Client(mcp) as client:
+ async with Client(mcp, mode="legacy") as client:
# Activate finance
await client.call_tool("activate_finance", {})
@@ -182,13 +182,13 @@ class TestSessionVisibility:
mcp.disable(tags={"finance"})
# Session A activates finance
- async with Client(mcp) as client_a:
+ async with Client(mcp, mode="legacy") as client_a:
await client_a.call_tool("activate_finance", {})
tools_a = await client_a.list_tools()
assert any(t.name == "finance_tool" for t in tools_a)
# Session B should not see finance tool (different session)
- async with Client(mcp) as client_b:
+ async with Client(mcp, mode="legacy") as client_b:
tools_b = await client_b.list_tools()
assert not any(t.name == "finance_tool" for t in tools_b)
@@ -220,7 +220,7 @@ class TestSessionVisibility:
# Globally disable all versioned tools
mcp.disable(names={"old_tool", "new_tool"})
- async with Client(mcp) as client:
+ async with Client(mcp, mode="legacy") as client:
# Enable v2 tools
await client.call_tool("enable_v2_only", {})
@@ -254,7 +254,7 @@ class TestSessionVisibility:
# Globally disable finance tools
mcp.disable(tags={"finance"})
- async with Client(mcp) as client:
+ async with Client(mcp, mode="legacy") as client:
# Activate finance
await client.call_tool("activate_finance", {})
tools_after_activate = await client.list_tools()
@@ -292,7 +292,7 @@ class TestSessionVisibility:
# Globally disable finance and admin tools
mcp.disable(tags={"finance", "admin"})
- async with Client(mcp) as client:
+ async with Client(mcp, mode="legacy") as client:
# Activate both
await client.call_tool("activate_multiple", {})
@@ -318,7 +318,7 @@ class TestSessionVisibility:
await ctx.disable_components(tags={"test"})
return "toggled"
- async with Client(mcp) as client:
+ async with Client(mcp, mode="legacy") as client:
# Toggle (enable then disable)
await client.call_tool("toggle_test", {})
@@ -344,7 +344,7 @@ class TestSessionVisibility:
# Globally disable finance resources
mcp.disable(tags={"finance"})
- async with Client(mcp) as client:
+ async with Client(mcp, mode="legacy") as client:
# Before activation, finance resource should not be visible
resources_before = await client.list_resources()
assert not any(str(r.uri) == "resource://finance" for r in resources_before)
@@ -374,7 +374,7 @@ class TestSessionVisibility:
# Globally disable finance prompts
mcp.disable(tags={"finance"})
- async with Client(mcp) as client:
+ async with Client(mcp, mode="legacy") as client:
# Before activation, finance prompt should not be visible
prompts_before = await client.list_prompts()
assert not any(p.name == "finance_prompt" for p in prompts_before)
@@ -402,7 +402,7 @@ class TestSessionVisibilityNotifications:
return "activated"
handler = RecordingMessageHandler()
- async with Client(mcp, message_handler=handler) as client:
+ async with Client(mcp, mode="legacy", message_handler=handler) as client:
handler.reset()
await client.call_tool("activate", {})
@@ -432,7 +432,7 @@ class TestSessionVisibilityNotifications:
return "deactivated"
handler = RecordingMessageHandler()
- async with Client(mcp, message_handler=handler) as client:
+ async with Client(mcp, mode="legacy", message_handler=handler) as client:
handler.reset()
await client.call_tool("deactivate", {})
@@ -461,7 +461,7 @@ class TestSessionVisibilityNotifications:
return "cleared"
handler = RecordingMessageHandler()
- async with Client(mcp, message_handler=handler) as client:
+ async with Client(mcp, mode="legacy", message_handler=handler) as client:
handler.reset()
await client.call_tool("clear", {})
@@ -491,7 +491,7 @@ class TestSessionVisibilityNotifications:
return "activated"
handler = RecordingMessageHandler()
- async with Client(mcp, message_handler=handler) as client:
+ async with Client(mcp, mode="legacy", message_handler=handler) as client:
handler.reset()
await client.call_tool("activate_tools_only", {})
@@ -537,7 +537,7 @@ class TestConcurrentSessionIsolation:
async def session_a():
nonlocal session_a_sees_finance
- async with Client(mcp) as client:
+ async with Client(mcp, mode="legacy") as client:
# Activate finance for this session
await client.call_tool("activate_finance", {})
@@ -556,7 +556,7 @@ class TestConcurrentSessionIsolation:
# Wait for session A to activate
await ready_event.wait()
- async with Client(mcp) as client:
+ async with Client(mcp, mode="legacy") as client:
# Session B should NOT see finance tool
tools = await client.list_tools()
session_b_sees_finance = any(t.name == "finance_tool" for t in tools)
@@ -590,13 +590,13 @@ class TestConcurrentSessionIsolation:
results: dict[str, bool] = {}
async def activated_session(session_id: str):
- async with Client(mcp) as client:
+ async with Client(mcp, mode="legacy") as client:
await client.call_tool("activate_premium", {})
tools = await client.list_tools()
results[session_id] = any(t.name == "premium_tool" for t in tools)
async def non_activated_session(session_id: str):
- async with Client(mcp) as client:
+ async with Client(mcp, mode="legacy") as client:
tools = await client.list_tools()
results[session_id] = any(t.name == "premium_tool" for t in tools)
@@ -644,7 +644,7 @@ class TestSessionVisibilityResetBug:
await ctx.reset_visibility()
return "exited"
- async with Client(mcp) as client:
+ async with Client(mcp, mode="legacy") as client:
# Tool visible initially
tools = await client.list_tools()
assert any(t.name == "my_tool" for t in tools)
@@ -681,7 +681,7 @@ class TestSessionVisibilityResetBug:
await ctx.reset_visibility()
return "exited"
- async with Client(mcp) as client:
+ async with Client(mcp, mode="legacy") as client:
for i in range(3):
# create_project should be visible
tools = await client.list_tools()
@@ -719,7 +719,7 @@ class TestSessionVisibilityResetBug:
check_done = anyio.Event()
async def session_a():
- async with Client(mcp) as client:
+ async with Client(mcp, mode="legacy") as client:
await client.call_tool("disable_system", {})
ready.set()
await check_done.wait()
@@ -727,7 +727,7 @@ class TestSessionVisibilityResetBug:
async def session_b():
nonlocal session_b_sees_tool
await ready.wait()
- async with Client(mcp) as client:
+ async with Client(mcp, mode="legacy") as client:
tools = await client.list_tools()
session_b_sees_tool = any(t.name == "shared_tool" for t in tools)
check_done.set()
@@ -756,13 +756,13 @@ class TestSessionVisibilityResetBug:
return "disabled"
# Session A disables the tool (no reset)
- async with Client(mcp) as client_a:
+ async with Client(mcp, mode="legacy") as client_a:
await client_a.call_tool("disable_system", {})
tools = await client_a.list_tools()
assert not any(t.name == "shared_tool" for t in tools)
# Session B should see it fresh
- async with Client(mcp) as client_b:
+ async with Client(mcp, mode="legacy") as client_b:
tools = await client_b.list_tools()
assert any(t.name == "shared_tool" for t in tools), (
"New session should see shared_tool regardless of previous session"
diff --git a/tests/server/test_tool_annotations.py b/tests/server/test_tool_annotations.py
index 096fd451d..46bb44e5e 100644
--- a/tests/server/test_tool_annotations.py
+++ b/tests/server/test_tool_annotations.py
@@ -229,7 +229,7 @@ async def test_task_execution_auto_populated_for_task_enabled_tool():
"""A tool that runs in background."""
return f"Processed: {data}"
- async with Client(mcp) as client:
+ async with Client(mcp, mode="legacy") as client:
tools_result = await client.list_tools()
assert len(tools_result) == 1
assert tools_result[0].name == "background_tool"
diff --git a/tests/server/transforms/test_search.py b/tests/server/transforms/test_search.py
index 981d044f2..241743ce2 100644
--- a/tests/server/transforms/test_search.py
+++ b/tests/server/transforms/test_search.py
@@ -188,7 +188,7 @@ class TestBaseTransformBehavior:
await ctx.disable_components(names={"delete_record"})
return "disabled"
- async with Client(mcp) as client:
+ async with Client(mcp, mode="legacy") as client:
# Before disabling, search should find delete_record
result = await client.call_tool("search_tools", {"pattern": "delete"})
found = _parse_tool_result(result)
diff --git a/tests/test_apps.py b/tests/test_apps.py
index dac7276cc..6aa65afed 100644
--- a/tests/test_apps.py
+++ b/tests/test_apps.py
@@ -417,14 +417,14 @@ class TestExtensionAdvertisement:
experimental_capabilities={"file_exchange": {"version": "0.3"}},
)
- async with Client(server) as client:
+ async with Client(server, mode="legacy") as client:
experimental = client.initialize_result.capabilities.experimental or {}
assert experimental.get("file_exchange") == {"version": "0.3"}
async def test_experimental_capabilities_default_empty(self):
server = FastMCP("test")
- async with Client(server) as client:
+ async with Client(server, mode="legacy") as client:
experimental = client.initialize_result.capabilities.experimental
assert not experimental
diff --git a/tests/test_compat.py b/tests/test_compat.py
index bb2c1233a..ec38cd6f6 100644
--- a/tests/test_compat.py
+++ b/tests/test_compat.py
@@ -231,7 +231,7 @@ class TestClientBehaviorCompat:
assert result.data == "hi"
async def test_ping_returns_bool(self, server):
- client = Client(transport=FastMCPTransport(server))
+ client = Client(transport=FastMCPTransport(server), mode="legacy")
async with client:
result = await client.ping()
assert result is True
diff --git a/tests/test_mcp_config.py b/tests/test_mcp_config.py
index 9c028da0f..39508b479 100644
--- a/tests/test_mcp_config.py
+++ b/tests/test_mcp_config.py
@@ -93,6 +93,47 @@ class InMemoryStdioMCPServer(StdioMCPServer):
return FastMCPTransport(mcp=self.mcp)
+class TestConfigTransportLegacyOnly:
+ """`MCPConfigTransport.legacy_only` gating (regression for the over-broad flag).
+
+ A single-server config delegates directly to the underlying transport with no
+ proxy, so it must mirror that transport's era capability rather than being
+ forced legacy. Only the multi-server composite (backed by legacy-era
+ ProxyClients) is legacy-only.
+ """
+
+ def test_single_modern_capable_server_is_not_forced_legacy(self):
+ """A single Streamable HTTP backend stays modern-capable under mode='auto'."""
+ config = {
+ "mcpServers": {"only": {"url": "https://example.com/mcp"}},
+ }
+ transport = MCPConfigTransport(config)
+ assert isinstance(transport.transport, StreamableHttpTransport)
+ assert transport.legacy_only is False
+
+ def test_single_sse_server_mirrors_legacy_only(self):
+ """A single SSE backend is legacy-only because SSE cannot serve modern."""
+ config = {
+ "mcpServers": {
+ "only": {"url": "https://example.com/sse", "transport": "sse"}
+ },
+ }
+ transport = MCPConfigTransport(config)
+ assert isinstance(transport.transport, SSETransport)
+ assert transport.legacy_only is True
+
+ def test_multi_server_config_is_legacy_only(self):
+ """A multi-server composite is legacy-only regardless of backend eras."""
+ config = {
+ "mcpServers": {
+ "a": {"url": "https://a.example.com/mcp"},
+ "b": {"url": "https://b.example.com/mcp"},
+ },
+ }
+ transport = MCPConfigTransport(config)
+ assert transport.legacy_only is True
+
+
def test_parse_single_stdio_config():
config = {
"mcpServers": {
diff --git a/tests/tools/tool_transform/test_tool_transform.py b/tests/tools/tool_transform/test_tool_transform.py
index f9e50df72..8e3f79350 100644
--- a/tests/tools/tool_transform/test_tool_transform.py
+++ b/tests/tools/tool_transform/test_tool_transform.py
@@ -736,7 +736,7 @@ class TestProxy:
)
proxy_server.add_tool(new_add_tool)
- async with Client(proxy_server) as client:
+ async with Client(proxy_server, mode="legacy") as client:
# The tool should be registered with its transformed name
result = await client.call_tool("add_transformed", {"new_x": 1, "old_y": 2})
assert isinstance(result.content[0], TextContent)