Compare commits

...

4 commits

Author SHA1 Message Date
Jeremiah Lowin
7d6c36a85d
Pin legacy for handshake-era features after mode=auto flip 2026-07-09 11:14:16 -04:00
Jeremiah Lowin
93fe4e89e0
Document client extensions and mode=auto default 2026-07-09 10:44:47 -04:00
Jeremiah Lowin
a72e51de84
Flip Client mode default from legacy to auto 2026-07-09 10:42:36 -04:00
Jeremiah Lowin
08ef2ac307
Surface extensions= and result_claims= on fastmcp.Client 2026-07-09 10:16:13 -04:00
74 changed files with 1167 additions and 541 deletions

View file

@ -166,19 +166,19 @@ async with client:
MCP has two protocol eras: the original *legacy* era, which begins every connection with an `initialize` handshake, and the *modern* era (protocol version `2026-07-28` and later), which a client discovers by probing the server's `server/discover` endpoint. The `mode` parameter controls which era the client negotiates when it connects.
By default, `mode="legacy"`. This runs the initialize handshake and behaves identically to earlier FastMCP versions, so existing code connecting to any server keeps working unchanged.
By default, `mode="auto"`. The client probes `server/discover` and adopts the modern protocol when the server responds; for any server that is not positive evidence of modern support, it falls back to the legacy handshake. This makes the default safe against a mixed fleet of legacy and modern servers.
```python
from fastmcp import Client
# Legacy handshake (the default)
client = Client("https://example.com/mcp", mode="legacy")
# Negotiate the newest era the server supports (the default)
client = Client("https://example.com/mcp", mode="auto")
```
Set `mode="auto"` to negotiate the newest era the server supports. The client probes `server/discover` and adopts the modern protocol when the server responds; for any server that is not positive evidence of modern support, it falls back to the legacy handshake. This makes `"auto"` safe to use against a mixed fleet of legacy and modern servers.
Set `mode="legacy"` to force the initialize handshake. This behaves identically to earlier FastMCP versions and is the opt-out if a server misbehaves under discovery or you need the legacy `initialize` result object.
```python
client = Client("https://example.com/mcp", mode="auto")
client = Client("https://example.com/mcp", mode="legacy")
```
You can also pin a specific modern protocol version to adopt it directly, without a discovery probe:
@ -196,7 +196,9 @@ async with Client("https://example.com/mcp", mode="auto") as client:
```
<Note>
`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"`.
</Note>
## Response caching
@ -234,6 +236,31 @@ async with client:
fresh = await client.list_tools_mcp(cache_mode="refresh")
```
## Client extensions
<VersionBadge version="4.0.0" />
Client extensions (SEP-2133) are the advanced mechanism a client uses to opt into vendor capabilities that live outside the core protocol. An extension is a `ClientExtension` instance that bundles three things: a capability *advertisement* the server can read, one or more *result claims* that let the client parse extra `tools/call` result shapes, and *notification bindings* that observe server notifications the core protocol doesn't define. Pass a sequence of them to `extensions=`.
```python
from fastmcp import Client
from myproject.extensions import AppsExtension
client = Client("https://example.com/mcp", extensions=[AppsExtension()])
```
Each extension's contributions are threaded into the underlying session. Notification bindings compose with FastMCP's own internal task-status binding rather than replacing it, so an extension that observes a custom notification and FastMCP's task tracking both work on the same connection. Result claims and their advertisements are honored only on modern-era connections, so they are inert on a legacy handshake.
For the rare case where you need to register additional result claims against an extension that is already advertised, pass them through `result_claims=`, keyed by the extension's identifier. Prefer declaring claims on the extension itself; this parameter merges extra claims with an extension's own.
```python
client = Client(
"https://example.com/mcp",
extensions=[AppsExtension()],
result_claims={"example.com/apps": [extra_claim]},
)
```
## Operations
FastMCP clients interact with three types of server components.

View file

@ -160,7 +160,28 @@ Resource-not-found responses from the core `resources/read` handler previously u
## Client
The `fastmcp.Client` public API is preserved exactly. The client stays a wrapper around `mcp.ClientSession` in legacy/handshake mode; the first-class `mcp.client.Client` is deliberately not adopted in this PR.
The `fastmcp.Client` public API is largely preserved. The client stays a wrapper around `mcp.ClientSession`; the first-class `mcp.client.Client` is deliberately not adopted in this PR. Two client-surface changes are called out below: the connection `mode` default flips to `"auto"`, and `extensions=` / `result_claims=` are newly surfaced.
### Connection `mode` defaults to `"auto"` — Breaking (behavior)
`Client(mode=...)` now defaults to `"auto"` instead of `"legacy"`. The client probes `server/discover` and adopts the modern (`2026-07-28`) era when the server responds, denylist-falling-back to the initialize handshake for any server that is not positive evidence of a modern peer. Against a FastMCP server (which serves both eras), an ordinary `Client(url)` now negotiates the modern era by default, where the legacy-only Context push features are unavailable per the per-feature era matrix (see the *Protocol eras* section below) — server-initiated sampling/elicitation/roots, `ping`, session ids, and FastMCP task submission all require the legacy era. The one-line revert is `Client(..., mode="legacy")`, which restores byte-identical pre-v4 negotiation.
The SSE transport is legacy-only (it cannot carry the sessionless modern era), so a client connecting over SSE negotiates the legacy handshake even under `mode="auto"` — expressed by a `ClientTransport.legacy_only` flag set on `SSETransport` and `MCPConfigTransport`. Two internal library seams 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), `docs/clients/client.mdx`.
### `extensions=` / `result_claims=` surfaced — New (opt-in feature)
`fastmcp.Client` now accepts `extensions=` (a sequence of SEP-2133 `ClientExtension` instances) and `result_claims=` (extra `ResultClaim`s keyed by an advertised extension's identifier). Each extension's capability advertisement, result claims, and notification bindings are folded into the underlying `ClientSession` on every transport. User-supplied notification bindings **compose** with FastMCP's internal task-status binding rather than clobbering it: the task binding always leads, and a user extension that binds the same method surfaces a clear duplicate-method error at connect time rather than silently winning. Claimed shapes are modern-only, so they are inert on a legacy connection.
*Verify:* `fastmcp_slim/fastmcp/client/client.py` (`_fold_extensions`, `_build_extension_kwargs`, `new()`), `fastmcp_slim/fastmcp/client/transports/base.py` (`SessionKwargs.extensions`/`result_claims`), `tests/client/test_client_extensions.py` (fold, composition, live both-bindings-fire).
### Transports yield 2-tuples — Absorbed

View file

@ -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,11 @@ from mcp.client.caching import (
ClientResponseCache,
InMemoryResponseCacheStore,
)
from mcp.client.extension import NotificationBinding
from mcp.client.extension import (
ClientExtension,
NotificationBinding,
ResultClaim,
)
from mcp.client.session import ClientRequestContext, MessageHandlerFnT
from mcp_types import (
GetTaskResult,
@ -120,11 +124,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.
@ -148,6 +152,86 @@ 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, and
`bindings` is the flat list of `NotificationBinding`s the extensions observe.
"""
ad: dict[str, dict[str, Any]]
claims: dict[str, tuple[ResultClaim[Any], ...]]
bindings: list[NotificationBinding[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.
"""
folded = _FoldedExtensions(ad={}, claims={}, bindings=[])
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
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:
@ -258,12 +342,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
@ -275,6 +360,18 @@ 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. 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
@ -364,10 +461,12 @@ class Client(
client_info: mcp_types.Implementation | None = None,
auth: httpx.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()
@ -452,6 +551,11 @@ 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
self._session_kwargs: SessionKwargs = {
"sampling_callback": None,
"list_roots_callback": None,
@ -459,10 +563,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:
@ -684,10 +785,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)}"
@ -735,13 +836,20 @@ class Client(
else:
timeout = normalize_timeout_to_seconds(timeout)
# A legacy-only transport (SSE) cannot serve the modern era; treat "auto"
# as "legacy" there rather than probing server/discover, which some servers
# answer over SSE 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.
@ -772,7 +880,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).
@ -1160,6 +1268,37 @@ 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.
"""
folded = _fold_extensions(self._extensions_arg)
claims: dict[str, tuple[ResultClaim[Any], ...]] = dict(folded.claims)
for identifier, extra in (self._result_claims_arg or {}).items():
existing = claims.get(identifier, ())
claims[identifier] = (*existing, *extra)
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
def _task_status_binding(self) -> NotificationBinding[TaskStatusNotificationParams]:
"""Build a binding routing `notifications/tasks/status` to Task objects.

View file

@ -1,12 +1,12 @@
import abc
import contextlib
from collections.abc import AsyncIterator, Sequence
from collections.abc import AsyncIterator, Mapping, Sequence
from typing import Any, Literal, TypeVar
import httpx
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,
@ -32,6 +32,8 @@ class SessionKwargs(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
class ClientTransport(abc.ABC):
@ -43,6 +45,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(

View file

@ -72,6 +72,11 @@ class MCPConfigTransport(ClientTransport):
```
"""
# This transport fronts a proxy whose backend ProxyClient is legacy-only
# (proxy forwarding relies on the handshake era), so the composite server it
# exposes is legacy-era; a client with mode="auto" negotiates the handshake.
legacy_only = True
def __init__(self, config: MCPConfig | dict, name_as_prefix: bool = True):
if isinstance(config, dict):
config = MCPConfig.from_dict(config)

View file

@ -25,6 +25,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,

View file

@ -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:

View file

@ -1116,6 +1116,12 @@ class ProxyClient(Client[ClientTransportT]):
):
if "name" not in kwargs:
kwargs["name"] = self.generate_name()
# Proxy forwarding is built on the legacy handshake era: it relays
# server-initiated roots/sampling/elicitation/logging (unavailable on
# the sessionless modern era) and forwards the backend's initialize
# result. Default the backend connection to legacy unless the caller
# explicitly opts into another mode.
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]

View file

@ -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()

View file

@ -79,10 +79,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):

View file

@ -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()

View file

@ -341,7 +341,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 +353,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 +362,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 +397,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 +407,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

View file

@ -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()

View file

@ -4,25 +4,35 @@ 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.
* ``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 +57,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,6 +77,12 @@ class TestAutoMode:
result = await client.call_tool("add", {"a": 4, "b": 5})
assert result.data == 9
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
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.
@ -91,6 +101,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):

View file

@ -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

View file

@ -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

View file

@ -43,7 +43,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)
@ -55,7 +55,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)
@ -79,7 +79,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
@ -108,7 +108,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
@ -135,7 +135,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)
@ -161,7 +161,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)
@ -179,7 +179,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
@ -196,7 +196,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):
@ -212,7 +212,7 @@ async def test_notification_with_failed_task(task_notification_server):
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.

View file

@ -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)

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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"})

View file

@ -0,0 +1,218 @@
"""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, and that both bindings actually fire against a live server.
"""
import asyncio
from typing import Any, Literal
import pytest
from mcp.client.extension import (
ClaimContext,
ClientExtension,
NotificationBinding,
ResultClaim,
)
from mcp_types import CallToolResult, Result
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"
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:
return CallToolResult(content=[])
def _make_claim() -> ResultClaim[ClaimedResult]:
return ResultClaim(
result_type="x-test/claimed",
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,
),
)
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]] == ["x-test/claimed"]
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
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}}
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 == {"x-test/claimed", "x-test/extra"}
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]

View file

@ -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"
@ -138,7 +140,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"
@ -167,7 +171,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"
@ -191,7 +197,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"
@ -216,7 +224,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", {})
@ -237,7 +247,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", {})
@ -263,7 +275,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"
@ -283,7 +297,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"
@ -306,7 +322,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
@ -328,7 +346,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
@ -348,7 +368,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"
):
@ -368,7 +390,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"
@ -386,7 +410,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
@ -404,7 +430,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
@ -422,7 +450,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
@ -442,7 +472,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"
@ -464,7 +496,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"
@ -482,7 +516,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"
@ -506,7 +542,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
@ -549,7 +587,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
@ -621,7 +661,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"
@ -668,7 +710,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
@ -748,7 +792,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!"
@ -773,7 +819,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"
@ -798,6 +846,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"

View file

@ -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"

View file

@ -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(

View file

@ -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)

View file

@ -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",

View file

@ -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:

View file

@ -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})

View file

@ -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

View file

@ -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", {})

View file

@ -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"}
)

View file

@ -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)

View file

@ -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)

View file

@ -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

View file

@ -97,7 +97,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 +106,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 +121,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 +130,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 +163,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 +175,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 +203,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 +221,7 @@ class TestProxyClient:
async with Client(
proxy_server,
mode="legacy",
elicitation_handler=elicitation_handler,
) as client:
result = await client.call_tool("elicit", {})
@ -233,7 +238,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 +256,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 +284,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 +302,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 +345,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 +409,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 +419,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 +501,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 +519,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 +537,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

View file

@ -176,7 +176,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!"
@ -184,7 +184,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!"
@ -218,7 +218,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
@ -230,7 +230,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
@ -242,7 +242,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
@ -265,7 +265,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()
@ -322,7 +322,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
@ -332,31 +332,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):
@ -373,7 +373,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):
@ -386,7 +386,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):
@ -403,7 +403,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
@ -422,7 +422,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)
@ -438,7 +438,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"
@ -451,7 +451,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"]
@ -474,27 +474,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)
@ -507,11 +507,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
@ -540,7 +540,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):
@ -552,7 +552,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! 🌊"
@ -566,7 +566,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"
@ -593,15 +593,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]
@ -609,9 +609,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
@ -620,11 +620,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
@ -662,7 +662,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)
@ -678,7 +678,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}"
@ -696,8 +696,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"
@ -709,8 +709,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"
@ -722,8 +722,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"
@ -735,8 +735,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"
@ -748,8 +748,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"
@ -769,8 +769,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"
@ -791,23 +791,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)
@ -822,7 +822,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"}
)
@ -842,7 +842,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
@ -853,9 +853,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

View file

@ -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!"

View file

@ -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"

View file

@ -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.
"""
@ -272,7 +272,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)
@ -304,14 +304,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.
"""
@ -329,7 +329,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)
@ -350,7 +350,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)
@ -394,7 +394,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"
@ -429,7 +429,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()
@ -450,7 +452,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()
@ -472,7 +474,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()
@ -498,7 +500,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()
@ -512,7 +514,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)
@ -530,7 +532,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
@ -556,7 +558,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"
@ -570,7 +572,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"

View file

@ -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

View file

@ -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)

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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()

View file

@ -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)

View file

@ -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

View file

@ -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
)

View file

@ -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"

View file

@ -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)

View file

@ -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()

View file

@ -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

View file

@ -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)

View file

@ -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

View file

@ -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

View file

@ -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)

View file

@ -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)

View file

@ -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

View file

@ -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)

View file

@ -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):

View file

@ -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

View file

@ -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)

View file

@ -43,7 +43,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")
@ -78,7 +80,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")

View file

@ -447,7 +447,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()
@ -468,7 +468,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()

View file

@ -35,7 +35,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
@ -44,7 +44,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
@ -289,7 +289,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
@ -318,7 +318,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

View file

@ -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"

View file

@ -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"

View file

@ -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"

View file

@ -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)

View file

@ -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

View file

@ -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

View file

@ -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)