From 08ef2ac3072481ddf8e61e8b73069234169327af Mon Sep 17 00:00:00 2001
From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com>
Date: Thu, 9 Jul 2026 10:16:13 -0400
Subject: [PATCH 1/4] Surface extensions= and result_claims= on fastmcp.Client
---
fastmcp_slim/fastmcp/client/client.py | 151 +++++++++++-
.../fastmcp/client/transports/base.py | 6 +-
tests/client/test_client_extensions.py | 217 ++++++++++++++++++
3 files changed, 362 insertions(+), 12 deletions(-)
create mode 100644 tests/client/test_client_extensions.py
diff --git a/fastmcp_slim/fastmcp/client/client.py b/fastmcp_slim/fastmcp/client/client.py
index 2106c38c7..1d2bfaea5 100644
--- a/fastmcp_slim/fastmcp/client/client.py
+++ b/fastmcp_slim/fastmcp/client/client.py
@@ -8,7 +8,7 @@ import secrets
import ssl
import uuid
import weakref
-from collections.abc import Callable, Coroutine
+from collections.abc import Callable, Coroutine, Mapping, Sequence
from contextlib import AsyncExitStack, asynccontextmanager, suppress
from dataclasses import dataclass, field
from pathlib import Path
@@ -31,7 +31,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,
@@ -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:
@@ -275,6 +359,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
@@ -368,6 +464,8 @@ class Client(
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 +550,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 +562,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 +784,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)}"
@@ -1160,6 +1260,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.
diff --git a/fastmcp_slim/fastmcp/client/transports/base.py b/fastmcp_slim/fastmcp/client/transports/base.py
index 87326413c..8151f8040 100644
--- a/fastmcp_slim/fastmcp/client/transports/base.py
+++ b/fastmcp_slim/fastmcp/client/transports/base.py
@@ -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):
diff --git a/tests/client/test_client_extensions.py b/tests/client/test_client_extensions.py
new file mode 100644
index 000000000..1f2fb72ba
--- /dev/null
+++ b/tests/client/test_client_extensions.py
@@ -0,0 +1,217 @@
+"""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 the same tool emits a custom notification the user extension
+ observes, proving the two coexist on one live connection.
+ """
+ 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)])
+
+ 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]
From a72e51de848569916f6fb25d0d0f04614d80b23a Mon Sep 17 00:00:00 2001
From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com>
Date: Thu, 9 Jul 2026 10:42:36 -0400
Subject: [PATCH 2/4] Flip Client mode default from legacy to auto
---
fastmcp_slim/fastmcp/client/client.py | 36 +++---
.../fastmcp/client/transports/base.py | 7 ++
fastmcp_slim/fastmcp/client/transports/sse.py | 4 +
tests/client/auth/test_oauth_client.py | 17 ++-
tests/client/auth/test_oauth_static_client.py | 1 +
tests/client/client/test_client.py | 10 +-
tests/client/client/test_initialize.py | 20 ++--
tests/client/client/test_mode_negotiation.py | 89 +++++++++++++--
.../client/tasks/test_client_prompt_tasks.py | 10 +-
.../tasks/test_client_resource_tasks.py | 12 +-
.../tasks/test_client_task_notifications.py | 18 +--
.../client/tasks/test_client_task_protocol.py | 6 +-
tests/client/tasks/test_client_tool_tasks.py | 18 +--
.../tasks/test_task_context_validation.py | 24 ++--
.../client/tasks/test_task_result_caching.py | 26 ++---
tests/client/telemetry/test_client_tracing.py | 4 +-
tests/client/test_client_extensions.py | 7 +-
tests/client/test_elicitation.py | 106 +++++++++++++-----
tests/client/test_elicitation_enums.py | 20 +++-
tests/client/test_logs.py | 12 +-
tests/client/test_roots.py | 3 +-
tests/client/test_sampling.py | 17 ++-
tests/client/test_streamable_http.py | 17 ++-
.../transports/test_memory_transport.py | 2 +-
tests/server/test_protocol_eras.py | 2 +-
25 files changed, 332 insertions(+), 156 deletions(-)
diff --git a/fastmcp_slim/fastmcp/client/client.py b/fastmcp_slim/fastmcp/client/client.py
index 1d2bfaea5..1f1e62f6d 100644
--- a/fastmcp_slim/fastmcp/client/client.py
+++ b/fastmcp_slim/fastmcp/client/client.py
@@ -124,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.
@@ -342,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
@@ -460,7 +461,7 @@ 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,
@@ -835,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.
@@ -872,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).
diff --git a/fastmcp_slim/fastmcp/client/transports/base.py b/fastmcp_slim/fastmcp/client/transports/base.py
index 8151f8040..584d6bf23 100644
--- a/fastmcp_slim/fastmcp/client/transports/base.py
+++ b/fastmcp_slim/fastmcp/client/transports/base.py
@@ -45,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(
diff --git a/fastmcp_slim/fastmcp/client/transports/sse.py b/fastmcp_slim/fastmcp/client/transports/sse.py
index 09e7fff5c..6089ad2e5 100644
--- a/fastmcp_slim/fastmcp/client/transports/sse.py
+++ b/fastmcp_slim/fastmcp/client/transports/sse.py
@@ -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,
diff --git a/tests/client/auth/test_oauth_client.py b/tests/client/auth/test_oauth_client.py
index e069270e0..0bc9ba94f 100644
--- a/tests/client/auth/test_oauth_client.py
+++ b/tests/client/auth/test_oauth_client.py
@@ -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):
diff --git a/tests/client/auth/test_oauth_static_client.py b/tests/client/auth/test_oauth_static_client.py
index c9f17cdbe..a622885fd 100644
--- a/tests/client/auth/test_oauth_static_client.py
+++ b/tests/client/auth/test_oauth_static_client.py
@@ -221,6 +221,7 @@ class TestStaticClientE2E:
async with Client(
transport=StreamableHttpTransport(url),
auth=oauth,
+ mode="legacy", # `ping` is a handshake-era request
) as client:
assert await client.ping()
tools = await client.list_tools()
diff --git a/tests/client/client/test_client.py b/tests/client/client/test_client.py
index 86355ce9e..966613bf8 100644
--- a/tests/client/client/test_client.py
+++ b/tests/client/client/test_client.py
@@ -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
diff --git a/tests/client/client/test_initialize.py b/tests/client/client/test_initialize.py
index 7bbe3aeb8..7106806ae 100644
--- a/tests/client/client/test_initialize.py
+++ b/tests/client/client/test_initialize.py
@@ -9,7 +9,7 @@ class TestInitialize:
async def test_auto_initialize_default(self, fastmcp_server):
"""Test that auto_initialize=True is the default and works automatically."""
- client = Client(fastmcp_server)
+ client = Client(fastmcp_server, mode="legacy")
async with client:
# Should be automatically initialized
@@ -19,7 +19,7 @@ class TestInitialize:
async def test_auto_initialize_explicit_true(self, fastmcp_server):
"""Test explicit auto_initialize=True."""
- client = Client(fastmcp_server, auto_initialize=True)
+ client = Client(fastmcp_server, mode="legacy", auto_initialize=True)
async with client:
assert client.initialize_result is not None
@@ -27,7 +27,7 @@ class TestInitialize:
async def test_auto_initialize_false(self, fastmcp_server):
"""Test that auto_initialize=False prevents automatic initialization."""
- client = Client(fastmcp_server, auto_initialize=False)
+ client = Client(fastmcp_server, mode="legacy", auto_initialize=False)
async with client:
# Should not be automatically initialized
@@ -35,7 +35,7 @@ class TestInitialize:
async def test_manual_initialize(self, fastmcp_server):
"""Test manual initialization when auto_initialize=False."""
- client = Client(fastmcp_server, auto_initialize=False)
+ client = Client(fastmcp_server, mode="legacy", auto_initialize=False)
async with client:
# Manually initialize
@@ -47,7 +47,7 @@ class TestInitialize:
async def test_initialize_idempotent(self, fastmcp_server):
"""Test that calling initialize() multiple times returns cached result."""
- client = Client(fastmcp_server, auto_initialize=False)
+ client = Client(fastmcp_server, mode="legacy", auto_initialize=False)
async with client:
result1 = await client.initialize()
@@ -66,7 +66,7 @@ class TestInitialize:
def greet(name: str) -> str:
return f"Hello, {name}!"
- client = Client(server)
+ client = Client(server, mode="legacy")
async with client:
result = client.initialize_result
@@ -75,7 +75,7 @@ class TestInitialize:
async def test_initialize_timeout_custom(self, fastmcp_server):
"""Test custom timeout for initialize()."""
- client = Client(fastmcp_server, auto_initialize=False)
+ client = Client(fastmcp_server, mode="legacy", auto_initialize=False)
async with client:
# Should succeed with reasonable timeout
@@ -84,7 +84,7 @@ class TestInitialize:
async def test_initialize_property_after_auto_init(self, fastmcp_server):
"""Test accessing initialize_result property after auto-initialization."""
- client = Client(fastmcp_server, auto_initialize=True)
+ client = Client(fastmcp_server, mode="legacy", auto_initialize=True)
async with client:
# Access via property
@@ -98,14 +98,14 @@ class TestInitialize:
async def test_initialize_property_before_connect(self, fastmcp_server):
"""Test that initialize_result property is None before connection."""
- client = Client(fastmcp_server)
+ client = Client(fastmcp_server, mode="legacy")
# Not yet connected
assert client.initialize_result is None
async def test_manual_initialize_can_call_tools(self, fastmcp_server):
"""Test that manually initialized client can call tools."""
- client = Client(fastmcp_server, auto_initialize=False)
+ client = Client(fastmcp_server, mode="legacy", auto_initialize=False)
async with client:
await client.initialize()
diff --git a/tests/client/client/test_mode_negotiation.py b/tests/client/client/test_mode_negotiation.py
index fc0b14f62..6c558e574 100644
--- a/tests/client/client/test_mode_negotiation.py
+++ b/tests/client/client/test_mode_negotiation.py
@@ -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):
diff --git a/tests/client/tasks/test_client_prompt_tasks.py b/tests/client/tasks/test_client_prompt_tasks.py
index 57f55e0a3..069c44c3b 100644
--- a/tests/client/tasks/test_client_prompt_tasks.py
+++ b/tests/client/tasks/test_client_prompt_tasks.py
@@ -31,7 +31,7 @@ async def prompt_server():
async def test_get_prompt_as_task_returns_prompt_task(prompt_server):
"""get_prompt with task=True returns a PromptTask object."""
- async with Client(prompt_server) as client:
+ async with Client(prompt_server, mode="legacy") as client:
task = await client.get_prompt("analysis_prompt", {"topic": "AI"}, task=True)
assert isinstance(task, PromptTask)
@@ -40,7 +40,7 @@ async def test_get_prompt_as_task_returns_prompt_task(prompt_server):
async def test_prompt_task_server_generated_id(prompt_server):
"""get_prompt with task=True gets server-generated task ID."""
- async with Client(prompt_server) as client:
+ async with Client(prompt_server, mode="legacy") as client:
task = await client.get_prompt(
"creative_prompt",
{"theme": "future"},
@@ -62,7 +62,7 @@ async def test_prompt_task_server_generated_id(prompt_server):
)
async def test_prompt_task_result_returns_get_prompt_result(prompt_server):
"""PromptTask.result() returns GetPromptResult."""
- async with Client(prompt_server) as client:
+ async with Client(prompt_server, mode="legacy") as client:
task = await client.get_prompt(
"analysis_prompt", {"topic": "Robotics", "style": "casual"}, task=True
)
@@ -83,7 +83,7 @@ async def test_prompt_task_result_returns_get_prompt_result(prompt_server):
async def test_prompt_task_await_syntax(prompt_server):
"""PromptTask can be awaited directly."""
- async with Client(prompt_server) as client:
+ async with Client(prompt_server, mode="legacy") as client:
task = await client.get_prompt("creative_prompt", {"theme": "ocean"}, task=True)
# Can await task directly
@@ -93,7 +93,7 @@ async def test_prompt_task_await_syntax(prompt_server):
async def test_prompt_task_status_and_wait(prompt_server):
"""PromptTask supports status() and wait() methods."""
- async with Client(prompt_server) as client:
+ async with Client(prompt_server, mode="legacy") as client:
task = await client.get_prompt("analysis_prompt", {"topic": "Space"}, task=True)
# Check status
diff --git a/tests/client/tasks/test_client_resource_tasks.py b/tests/client/tasks/test_client_resource_tasks.py
index 0dda0366f..44ab5f826 100644
--- a/tests/client/tasks/test_client_resource_tasks.py
+++ b/tests/client/tasks/test_client_resource_tasks.py
@@ -31,7 +31,7 @@ async def resource_server():
async def test_read_resource_as_task_returns_resource_task(resource_server):
"""read_resource with task=True returns a ResourceTask object."""
- async with Client(resource_server) as client:
+ async with Client(resource_server, mode="legacy") as client:
task = await client.read_resource("file://document.txt", task=True)
assert isinstance(task, ResourceTask)
@@ -40,7 +40,7 @@ async def test_read_resource_as_task_returns_resource_task(resource_server):
async def test_resource_task_server_generated_id(resource_server):
"""read_resource with task=True gets server-generated task ID."""
- async with Client(resource_server) as client:
+ async with Client(resource_server, mode="legacy") as client:
task = await client.read_resource("file://document.txt", task=True)
# Server should generate a UUID task ID
@@ -58,7 +58,7 @@ async def test_resource_task_server_generated_id(resource_server):
)
async def test_resource_task_result_returns_read_resource_result(resource_server):
"""ResourceTask.result() returns list of ReadResourceContents."""
- async with Client(resource_server) as client:
+ async with Client(resource_server, mode="legacy") as client:
task = await client.read_resource("file://document.txt", task=True)
# Verify background execution
@@ -75,7 +75,7 @@ async def test_resource_task_result_returns_read_resource_result(resource_server
async def test_resource_task_await_syntax(resource_server):
"""ResourceTask can be awaited directly."""
- async with Client(resource_server) as client:
+ async with Client(resource_server, mode="legacy") as client:
task = await client.read_resource("file://document.txt", task=True)
# Can await task directly
@@ -91,7 +91,7 @@ async def test_resource_task_await_syntax(resource_server):
)
async def test_resource_template_task(resource_server):
"""Resource templates work with task support."""
- async with Client(resource_server) as client:
+ async with Client(resource_server, mode="legacy") as client:
task = await client.read_resource("file://data/999.json", task=True)
# Verify background execution
@@ -104,7 +104,7 @@ async def test_resource_template_task(resource_server):
async def test_resource_task_status_and_wait(resource_server):
"""ResourceTask supports status() and wait() methods."""
- async with Client(resource_server) as client:
+ async with Client(resource_server, mode="legacy") as client:
task = await client.read_resource("file://document.txt", task=True)
# Check status
diff --git a/tests/client/tasks/test_client_task_notifications.py b/tests/client/tasks/test_client_task_notifications.py
index 94f7e1db3..d44c1db11 100644
--- a/tests/client/tasks/test_client_task_notifications.py
+++ b/tests/client/tasks/test_client_task_notifications.py
@@ -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.
diff --git a/tests/client/tasks/test_client_task_protocol.py b/tests/client/tasks/test_client_task_protocol.py
index e8b29afd9..343e69bf4 100644
--- a/tests/client/tasks/test_client_task_protocol.py
+++ b/tests/client/tasks/test_client_task_protocol.py
@@ -24,7 +24,7 @@ async def test_end_to_end_task_flow():
await complete_signal.wait()
return f"Processed: {message}"
- async with Client(mcp) as client:
+ async with Client(mcp, mode="legacy") as client:
# Submit task
task = await client.call_tool(
"controlled_tool", {"message": "integration test"}, task=True
@@ -53,7 +53,7 @@ async def test_multiple_concurrent_tasks():
async def multiply(a: int, b: int) -> int:
return a * b
- async with Client(mcp) as client:
+ async with Client(mcp, mode="legacy") as client:
# Submit multiple tasks
tasks = []
for i in range(5):
@@ -74,7 +74,7 @@ async def test_task_id_auto_generation():
async def echo(message: str) -> str:
return f"Echo: {message}"
- async with Client(mcp) as client:
+ async with Client(mcp, mode="legacy") as client:
# Submit without custom task ID
task_1 = await client.call_tool("echo", {"message": "first"}, task=True)
task_2 = await client.call_tool("echo", {"message": "second"}, task=True)
diff --git a/tests/client/tasks/test_client_tool_tasks.py b/tests/client/tasks/test_client_tool_tasks.py
index 0bec286cb..2bccedccb 100644
--- a/tests/client/tasks/test_client_tool_tasks.py
+++ b/tests/client/tasks/test_client_tool_tasks.py
@@ -33,7 +33,7 @@ async def tool_task_server():
async def test_call_tool_as_task_returns_tool_task(tool_task_server):
"""call_tool with task=True returns a ToolTask object."""
- async with Client(tool_task_server) as client:
+ async with Client(tool_task_server, mode="legacy") as client:
task = await client.call_tool("echo", {"message": "hello"}, task=True)
assert isinstance(task, ToolTask)
@@ -43,7 +43,7 @@ async def test_call_tool_as_task_returns_tool_task(tool_task_server):
async def test_tool_task_server_generated_id(tool_task_server):
"""call_tool with task=True gets server-generated task ID."""
- async with Client(tool_task_server) as client:
+ async with Client(tool_task_server, mode="legacy") as client:
task = await client.call_tool("echo", {"message": "test"}, task=True)
# Server should generate a UUID task ID
@@ -55,7 +55,7 @@ async def test_tool_task_server_generated_id(tool_task_server):
async def test_tool_task_result_returns_call_tool_result(tool_task_server):
"""ToolTask.result() returns CallToolResult with tool data."""
- async with Client(tool_task_server) as client:
+ async with Client(tool_task_server, mode="legacy") as client:
task = await client.call_tool("multiply", {"a": 6, "b": 7}, task=True)
assert not task.returned_immediately
@@ -65,7 +65,7 @@ async def test_tool_task_result_returns_call_tool_result(tool_task_server):
async def test_tool_task_await_syntax(tool_task_server):
"""Tool tasks can be awaited directly to get result."""
- async with Client(tool_task_server) as client:
+ async with Client(tool_task_server, mode="legacy") as client:
task = await client.call_tool("multiply", {"a": 7, "b": 6}, task=True)
# Can await task directly (syntactic sugar for task.result())
@@ -75,7 +75,7 @@ async def test_tool_task_await_syntax(tool_task_server):
async def test_tool_task_status_and_wait(tool_task_server):
"""ToolTask.status() returns GetTaskResult."""
- async with Client(tool_task_server) as client:
+ async with Client(tool_task_server, mode="legacy") as client:
task = await client.call_tool("echo", {"message": "test"}, task=True)
status = await task.status()
@@ -96,7 +96,7 @@ async def test_immediate_tool_task_respects_raise_on_error_true():
def failing_tool() -> str:
raise ValueError("immediate task failure")
- async with Client(mcp) as client:
+ async with Client(mcp, mode="legacy") as client:
task = await client.call_tool("failing_tool", task=True, raise_on_error=True)
assert task.returned_immediately
@@ -114,7 +114,7 @@ async def test_immediate_tool_task_respects_raise_on_error_false():
def failing_tool() -> str:
raise ValueError("immediate task failure")
- async with Client(mcp) as client:
+ async with Client(mcp, mode="legacy") as client:
task = await client.call_tool("failing_tool", task=True, raise_on_error=False)
assert task.returned_immediately
@@ -131,7 +131,7 @@ async def test_background_tool_task_respects_raise_on_error_true():
async def failing_tool() -> str:
raise ValueError("background task failure")
- async with Client(mcp) as client:
+ async with Client(mcp, mode="legacy") as client:
task = await client.call_tool("failing_tool", task=True, raise_on_error=True)
assert not task.returned_immediately
@@ -147,7 +147,7 @@ async def test_background_tool_task_respects_raise_on_error_false():
async def failing_tool() -> str:
raise ValueError("background task failure")
- async with Client(mcp) as client:
+ async with Client(mcp, mode="legacy") as client:
task = await client.call_tool("failing_tool", task=True, raise_on_error=False)
assert not task.returned_immediately
diff --git a/tests/client/tasks/test_task_context_validation.py b/tests/client/tasks/test_task_context_validation.py
index fb4765e52..2b6a76832 100644
--- a/tests/client/tasks/test_task_context_validation.py
+++ b/tests/client/tasks/test_task_context_validation.py
@@ -37,7 +37,7 @@ async def task_server():
async def test_task_status_outside_context_raises(task_server):
"""Calling task.status() outside client context raises error."""
task = None
- async with Client(task_server) as client:
+ async with Client(task_server, mode="legacy") as client:
task = await client.call_tool("background_tool", {"value": "test"}, task=True)
assert not task.returned_immediately
# Now outside context
@@ -49,7 +49,7 @@ async def test_task_status_outside_context_raises(task_server):
async def test_task_result_outside_context_raises(task_server):
"""Calling task.result() outside context raises error."""
task = None
- async with Client(task_server) as client:
+ async with Client(task_server, mode="legacy") as client:
task = await client.call_tool("background_tool", {"value": "test"}, task=True)
assert not task.returned_immediately
# Now outside context
@@ -61,7 +61,7 @@ async def test_task_result_outside_context_raises(task_server):
async def test_task_wait_outside_context_raises(task_server):
"""Calling task.wait() outside context raises error."""
task = None
- async with Client(task_server) as client:
+ async with Client(task_server, mode="legacy") as client:
task = await client.call_tool("background_tool", {"value": "test"}, task=True)
assert not task.returned_immediately
# Now outside context
@@ -73,7 +73,7 @@ async def test_task_wait_outside_context_raises(task_server):
async def test_task_cancel_outside_context_raises(task_server):
"""Calling task.cancel() outside context raises error."""
task = None
- async with Client(task_server) as client:
+ async with Client(task_server, mode="legacy") as client:
task = await client.call_tool("background_tool", {"value": "test"}, task=True)
assert not task.returned_immediately
# Now outside context
@@ -85,7 +85,7 @@ async def test_task_cancel_outside_context_raises(task_server):
async def test_cached_tool_task_accessible_outside_context(task_server):
"""Tool tasks with cached results work outside context."""
task = None
- async with Client(task_server) as client:
+ async with Client(task_server, mode="legacy") as client:
task = await client.call_tool("background_tool", {"value": "test"}, task=True)
assert not task.returned_immediately
@@ -109,7 +109,7 @@ async def test_cached_tool_task_accessible_outside_context(task_server):
async def test_cached_prompt_task_accessible_outside_context(task_server):
"""Prompt tasks with cached results work outside context."""
task = None
- async with Client(task_server) as client:
+ async with Client(task_server, mode="legacy") as client:
task = await client.get_prompt(
"background_prompt", {"topic": "test"}, task=True
)
@@ -135,7 +135,7 @@ async def test_cached_prompt_task_accessible_outside_context(task_server):
async def test_cached_resource_task_accessible_outside_context(task_server):
"""Resource tasks with cached results work outside context."""
task = None
- async with Client(task_server) as client:
+ async with Client(task_server, mode="legacy") as client:
task = await client.read_resource("file://background.txt", task=True)
assert not task.returned_immediately
@@ -152,7 +152,7 @@ async def test_cached_resource_task_accessible_outside_context(task_server):
async def test_uncached_status_outside_context_raises(task_server):
"""Even after caching result, status() still requires client context."""
task = None
- async with Client(task_server) as client:
+ async with Client(task_server, mode="legacy") as client:
task = await client.call_tool("background_tool", {"value": "test"}, task=True)
assert not task.returned_immediately
@@ -172,7 +172,7 @@ async def test_uncached_status_outside_context_raises(task_server):
async def test_task_await_syntax_outside_context_raises(task_server):
"""Using await task syntax outside context raises error for background tasks."""
task = None
- async with Client(task_server) as client:
+ async with Client(task_server, mode="legacy") as client:
task = await client.call_tool("background_tool", {"value": "test"}, task=True)
assert not task.returned_immediately
# Now outside context
@@ -184,7 +184,7 @@ async def test_task_await_syntax_outside_context_raises(task_server):
async def test_task_await_syntax_works_for_cached_results(task_server):
"""Using await task syntax works outside context when result is cached."""
task = None
- async with Client(task_server) as client:
+ async with Client(task_server, mode="legacy") as client:
task = await client.call_tool("background_tool", {"value": "test"}, task=True)
result1 = await task # Cache it
# Now outside context
@@ -196,7 +196,7 @@ async def test_task_await_syntax_works_for_cached_results(task_server):
async def test_multiple_result_calls_return_same_cached_object(task_server):
"""Multiple result() calls return the same cached object."""
- async with Client(task_server) as client:
+ async with Client(task_server, mode="legacy") as client:
task = await client.call_tool("background_tool", {"value": "test"}, task=True)
result1 = await task.result()
@@ -211,7 +211,7 @@ async def test_multiple_result_calls_return_same_cached_object(task_server):
async def test_background_task_properties_accessible_outside_context(task_server):
"""Background task properties like task_id accessible outside context."""
task = None
- async with Client(task_server) as client:
+ async with Client(task_server, mode="legacy") as client:
task = await client.call_tool("background_tool", {"value": "test"}, task=True)
task_id_inside = task.task_id
assert not task.returned_immediately
diff --git a/tests/client/tasks/test_task_result_caching.py b/tests/client/tasks/test_task_result_caching.py
index fdb48e129..183014c8e 100644
--- a/tests/client/tasks/test_task_result_caching.py
+++ b/tests/client/tasks/test_task_result_caching.py
@@ -22,7 +22,7 @@ async def test_tool_task_result_cached_on_first_call():
call_count += 1
return call_count
- async with Client(mcp) as client:
+ async with Client(mcp, mode="legacy") as client:
task = await client.call_tool("counting_tool", task=True)
result1 = await task.result()
@@ -49,7 +49,7 @@ async def test_prompt_task_result_cached():
call_count += 1
return f"Call number: {call_count}"
- async with Client(mcp) as client:
+ async with Client(mcp, mode="legacy") as client:
task = await client.get_prompt("counting_prompt", task=True)
result1 = await task.result()
@@ -76,7 +76,7 @@ async def test_resource_task_result_cached():
call_count += 1
return f"Count: {call_count}"
- async with Client(mcp) as client:
+ async with Client(mcp, mode="legacy") as client:
task = await client.read_resource("file://counter.txt", task=True)
result1 = await task.result()
@@ -100,7 +100,7 @@ async def test_multiple_await_returns_same_object():
async def sample_tool() -> str:
return "result"
- async with Client(mcp) as client:
+ async with Client(mcp, mode="legacy") as client:
task = await client.call_tool("sample_tool", task=True)
result1 = await task
@@ -120,7 +120,7 @@ async def test_result_and_await_share_cache():
async def sample_tool() -> str:
return "cached"
- async with Client(mcp) as client:
+ async with Client(mcp, mode="legacy") as client:
task = await client.call_tool("sample_tool", task=True)
# Call result() first
@@ -142,7 +142,7 @@ async def test_forbidden_mode_tool_caches_error_result():
async def non_task_tool() -> int:
return 1
- async with Client(mcp) as client:
+ async with Client(mcp, mode="legacy") as client:
# Request as task, but mode="forbidden" will reject with error
task = await client.call_tool("non_task_tool", task=True, raise_on_error=False)
@@ -178,7 +178,7 @@ async def test_forbidden_mode_prompt_raises_error():
async def non_task_prompt() -> str:
return "Immediate"
- async with Client(mcp) as client:
+ async with Client(mcp, mode="legacy") as client:
# Prompts with mode="forbidden" raise MCPError when called with task=True
with pytest.raises(MCPError):
await client.get_prompt("non_task_prompt", task=True)
@@ -201,7 +201,7 @@ async def test_forbidden_mode_resource_raises_error():
async def non_task_resource() -> str:
return "Immediate"
- async with Client(mcp) as client:
+ async with Client(mcp, mode="legacy") as client:
# Resources with mode="forbidden" raise MCPError when called with task=True
with pytest.raises(MCPError):
await client.read_resource("file://immediate.txt", task=True)
@@ -219,7 +219,7 @@ async def test_immediate_task_caches_result():
call_count += 1
return call_count
- async with Client(mcp) as client:
+ async with Client(mcp, mode="legacy") as client:
# Call with task=True
task = await client.call_tool("task_tool", task=True)
@@ -245,7 +245,7 @@ async def test_cache_persists_across_mixed_access_patterns():
async def mixed_tool() -> str:
return "mixed"
- async with Client(mcp) as client:
+ async with Client(mcp, mode="legacy") as client:
task = await client.call_tool("mixed_tool", task=True)
# Access in various orders
@@ -266,7 +266,7 @@ async def test_different_tasks_have_separate_caches():
async def separate_tool(value: str) -> str:
return f"Result: {value}"
- async with Client(mcp) as client:
+ async with Client(mcp, mode="legacy") as client:
task1 = await client.call_tool("separate_tool", {"value": "A"}, task=True)
task2 = await client.call_tool("separate_tool", {"value": "B"}, task=True)
@@ -296,7 +296,7 @@ async def test_cache_survives_status_checks():
async def status_check_tool() -> str:
return "status"
- async with Client(mcp) as client:
+ async with Client(mcp, mode="legacy") as client:
task = await client.call_tool("status_check_tool", task=True)
# Check status multiple times
@@ -322,7 +322,7 @@ async def test_cache_survives_wait_calls():
async def wait_test_tool() -> str:
return "waited"
- async with Client(mcp) as client:
+ async with Client(mcp, mode="legacy") as client:
task = await client.call_tool("wait_test_tool", task=True)
# Wait for completion
diff --git a/tests/client/telemetry/test_client_tracing.py b/tests/client/telemetry/test_client_tracing.py
index 1180a40dc..0ca2e8759 100644
--- a/tests/client/telemetry/test_client_tracing.py
+++ b/tests/client/telemetry/test_client_tracing.py
@@ -589,7 +589,7 @@ class TestSessionIdOnSpans:
from fastmcp.client.transports import StreamableHttpTransport
transport = StreamableHttpTransport(http_server_url)
- client = Client(transport=transport)
+ client = Client(transport=transport, mode="legacy")
async with client:
await client.call_tool("echo", {"message": "test"})
@@ -657,7 +657,7 @@ class TestSessionIdOnSpans:
from fastmcp.client.transports import StreamableHttpTransport
transport = StreamableHttpTransport(http_server_url)
- client = Client(transport=transport)
+ client = Client(transport=transport, mode="legacy")
async with client:
await client.call_tool("echo", {"message": "test"})
diff --git a/tests/client/test_client_extensions.py b/tests/client/test_client_extensions.py
index 1f2fb72ba..3122a203b 100644
--- a/tests/client/test_client_extensions.py
+++ b/tests/client/test_client_extensions.py
@@ -181,8 +181,9 @@ 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 the same tool emits a custom notification the user extension
- observes, proving the two coexist on one live connection.
+ 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")
@@ -200,7 +201,7 @@ async def test_both_bindings_fire_against_live_server():
await asyncio.sleep(0.02)
return value * 2
- client = Client(mcp, extensions=[_DemoExtension(received)])
+ client = Client(mcp, extensions=[_DemoExtension(received)], mode="legacy")
async with client:
# The user extension binding fires on the custom notification.
diff --git a/tests/client/test_elicitation.py b/tests/client/test_elicitation.py
index 1db306077..0df5df97f 100644
--- a/tests/client/test_elicitation.py
+++ b/tests/client/test_elicitation.py
@@ -51,7 +51,7 @@ def fastmcp_server():
async def test_elicitation_with_no_handler(fastmcp_server):
"""Test that elicitation works without a handler."""
- async with Client(fastmcp_server) as client:
+ async with Client(fastmcp_server, mode="legacy") as client:
with pytest.raises(ToolError, match="Elicitation not supported"):
await client.call_tool("ask_for_name")
@@ -64,7 +64,7 @@ async def test_elicitation_accept_content(fastmcp_server):
return ElicitResult(action="accept", content=response_type(name="Alice"))
async with Client(
- fastmcp_server, elicitation_handler=elicitation_handler
+ fastmcp_server, mode="legacy", elicitation_handler=elicitation_handler
) as client:
result = await client.call_tool("ask_for_name")
assert result.data == "Hello, Alice!"
@@ -77,7 +77,7 @@ async def test_elicitation_decline(fastmcp_server):
return ElicitResult(action="decline")
async with Client(
- fastmcp_server, elicitation_handler=elicitation_handler
+ fastmcp_server, mode="legacy", elicitation_handler=elicitation_handler
) as client:
result = await client.call_tool("ask_for_name")
assert result.data == "No name provided."
@@ -103,7 +103,9 @@ async def test_elicitation_handler_parameters():
captured_params["ctx"] = ctx
return ElicitResult(action="accept", content={"value": 42})
- async with Client(mcp, elicitation_handler=elicitation_handler) as client:
+ async with Client(
+ mcp, mode="legacy", elicitation_handler=elicitation_handler
+ ) as client:
await client.call_tool("test_tool", {})
assert captured_params["message"] == "Test message"
@@ -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"
diff --git a/tests/client/test_elicitation_enums.py b/tests/client/test_elicitation_enums.py
index baa281edd..fdd2442fd 100644
--- a/tests/client/test_elicitation_enums.py
+++ b/tests/client/test_elicitation_enums.py
@@ -54,7 +54,7 @@ async def test_elicitation_implicit_acceptance(fastmcp_server):
return response_type(name="Bob")
async with Client(
- fastmcp_server, elicitation_handler=elicitation_handler
+ fastmcp_server, mode="legacy", elicitation_handler=elicitation_handler
) as client:
result = await client.call_tool("ask_for_name")
assert result.data == "Hello, Bob!"
@@ -69,7 +69,7 @@ async def test_elicitation_implicit_acceptance_must_be_dict(fastmcp_server):
return "Bob"
async with Client(
- fastmcp_server, elicitation_handler=elicitation_handler
+ fastmcp_server, mode="legacy", elicitation_handler=elicitation_handler
) as client:
with pytest.raises(
ToolError,
@@ -182,7 +182,9 @@ async def test_dict_based_titled_single_select():
return ElicitResult(action="accept", content={"value": "low"})
- async with Client(mcp, elicitation_handler=elicitation_handler) as client:
+ async with Client(
+ mcp, mode="legacy", elicitation_handler=elicitation_handler
+ ) as client:
result = await client.call_tool("my_tool", {})
assert result.data == "low"
@@ -215,7 +217,9 @@ async def test_list_list_multi_select_untitled():
return ElicitResult(action="accept", content={"value": ["bug", "feature"]})
- async with Client(mcp, elicitation_handler=elicitation_handler) as client:
+ async with Client(
+ mcp, mode="legacy", elicitation_handler=elicitation_handler
+ ) as client:
result = await client.call_tool("my_tool", {})
assert result.data == "bug,feature"
@@ -256,7 +260,9 @@ async def test_list_dict_multi_select_titled():
return ElicitResult(action="accept", content={"value": ["low", "high"]})
- async with Client(mcp, elicitation_handler=elicitation_handler) as client:
+ async with Client(
+ mcp, mode="legacy", elicitation_handler=elicitation_handler
+ ) as client:
result = await client.call_tool("my_tool", {})
assert result.data == "low,high"
@@ -320,7 +326,9 @@ async def test_list_enum_multi_select_direct():
return ElicitResult(action="accept", content={"value": ["low", "high"]})
- async with Client(mcp, elicitation_handler=elicitation_handler) as client:
+ async with Client(
+ mcp, mode="legacy", elicitation_handler=elicitation_handler
+ ) as client:
result = await client.call_tool("my_tool", {})
assert result.data == "low,high"
diff --git a/tests/client/test_logs.py b/tests/client/test_logs.py
index 43b6e1d0f..34628ed4b 100644
--- a/tests/client/test_logs.py
+++ b/tests/client/test_logs.py
@@ -95,7 +95,9 @@ class TestSetLoggingLevel:
async def test_set_logging_level(self, fastmcp_server: FastMCP):
"""Client can set the minimum log level and lower-level messages are suppressed."""
log_handler = LogHandler()
- async with Client(fastmcp_server, log_handler=log_handler.handle_log) as client:
+ async with Client(
+ fastmcp_server, mode="legacy", log_handler=log_handler.handle_log
+ ) as client:
await client.set_logging_level("warning")
await client.call_tool(
"echo_log", {"message": "debug msg", "level": "debug"}
@@ -115,7 +117,9 @@ class TestSetLoggingLevel:
async def test_set_logging_level_debug_allows_all(self, fastmcp_server: FastMCP):
"""Setting level to debug allows all messages through."""
log_handler = LogHandler()
- async with Client(fastmcp_server, log_handler=log_handler.handle_log) as client:
+ async with Client(
+ fastmcp_server, mode="legacy", log_handler=log_handler.handle_log
+ ) as client:
await client.set_logging_level("debug")
await client.call_tool(
"echo_log", {"message": "debug msg", "level": "debug"}
@@ -169,7 +173,9 @@ class TestSetLoggingLevel:
await context.log(message=message, level=level)
log_handler = LogHandler()
- async with Client(mcp, log_handler=log_handler.handle_log) as client:
+ async with Client(
+ mcp, mode="legacy", log_handler=log_handler.handle_log
+ ) as client:
await client.set_logging_level("warning")
await client.call_tool("echo_log", {"message": "info msg", "level": "info"})
await client.call_tool(
diff --git a/tests/client/test_roots.py b/tests/client/test_roots.py
index d3bc7d5ca..e71f0c849 100644
--- a/tests/client/test_roots.py
+++ b/tests/client/test_roots.py
@@ -36,7 +36,8 @@ class TestClientRoots:
@pytest.mark.parametrize("roots", [["file://x/y/z", "file://x/y/z"]])
async def test_valid_roots(self, fastmcp_server: FastMCP, roots: list[str]):
- async with Client(fastmcp_server, roots=roots) as client:
+ # ctx.list_roots is a legacy-era server-initiated feature.
+ async with Client(fastmcp_server, mode="legacy", roots=roots) as client:
result = await client.call_tool("list_roots", {})
assert result.data == [
"file://x/y/z",
diff --git a/tests/client/test_sampling.py b/tests/client/test_sampling.py
index 2fc75e311..98ec74494 100644
--- a/tests/client/test_sampling.py
+++ b/tests/client/test_sampling.py
@@ -76,7 +76,9 @@ async def test_simple_sampling(fastmcp_server: FastMCP):
) -> str:
return "This is the sample message!"
- async with Client(fastmcp_server, sampling_handler=sampling_handler) as client:
+ async with Client(
+ fastmcp_server, mode="legacy", sampling_handler=sampling_handler
+ ) as client:
result = await client.call_tool("simple_sample", {"message": "Hello, world!"})
assert result.data == "This is the sample message!"
@@ -88,7 +90,9 @@ async def test_sampling_with_system_prompt(fastmcp_server: FastMCP):
assert params.system_prompt is not None
return params.system_prompt
- async with Client(fastmcp_server, sampling_handler=sampling_handler) as client:
+ async with Client(
+ fastmcp_server, mode="legacy", sampling_handler=sampling_handler
+ ) as client:
result = await client.call_tool(
"sample_with_system_prompt", {"message": "Hello, world!"}
)
@@ -110,7 +114,9 @@ async def test_sampling_with_messages(fastmcp_server: FastMCP):
assert messages[1].content.text == "How can I assist you today?"
return "I need to think."
- async with Client(fastmcp_server, sampling_handler=sampling_handler) as client:
+ async with Client(
+ fastmcp_server, mode="legacy", sampling_handler=sampling_handler
+ ) as client:
result = await client.call_tool(
"sample_with_messages", {"message": "Hello, world!"}
)
@@ -144,7 +150,9 @@ async def test_sampling_with_image(fastmcp_server: FastMCP):
assert len(messages) == 2
return to_json(messages).decode()
- async with Client(fastmcp_server, sampling_handler=sampling_handler) as client:
+ async with Client(
+ fastmcp_server, mode="legacy", sampling_handler=sampling_handler
+ ) as client:
image_bytes = b"abc123"
result = await client.call_tool(
"sample_with_image", {"image_bytes": image_bytes}
@@ -264,6 +272,7 @@ class TestSamplingWithTools:
# Explicitly disable tools capability by passing SamplingCapability without tools
async with Client(
server,
+ mode="legacy",
sampling_handler=sampling_handler,
sampling_capabilities=mcp_types.SamplingCapability(), # No tools
) as client:
diff --git a/tests/client/test_streamable_http.py b/tests/client/test_streamable_http.py
index e4ea6e68b..26971eda4 100644
--- a/tests/client/test_streamable_http.py
+++ b/tests/client/test_streamable_http.py
@@ -150,7 +150,7 @@ async def nested_server():
async def test_ping(streamable_http_server: str):
"""Test pinging the server."""
async with Client(
- transport=StreamableHttpTransport(streamable_http_server)
+ transport=StreamableHttpTransport(streamable_http_server), mode="legacy"
) as client:
result = await client.ping()
assert result is True
@@ -163,7 +163,8 @@ async def test_ping_with_streamable_http_alias(
async with Client(
transport=StreamableHttpTransport(
streamable_http_server_with_streamable_http_alias
- )
+ ),
+ mode="legacy",
) as client:
result = await client.ping()
assert result is True
@@ -187,7 +188,7 @@ async def test_session_id_callback(streamable_http_server: str):
"""Test getting mcp-session-id from the transport."""
transport = StreamableHttpTransport(streamable_http_server)
assert transport.get_session_id() is None
- async with Client(transport=transport):
+ async with Client(transport=transport, mode="legacy"):
session_id = transport.get_session_id()
assert session_id is not None
@@ -226,6 +227,7 @@ async def test_elicitation_tool(streamable_http_server: str, request):
async with Client(
transport=StreamableHttpTransport(streamable_http_server),
elicitation_handler=elicitation_handler,
+ mode="legacy",
) as client:
result = await client.call_tool("elicit")
assert result.data == "You said your name was: Alice!"
@@ -253,7 +255,9 @@ async def test_stateless_http_still_accepts_post(streamable_http_server: str):
async def test_nested_streamable_http_server_resolves_correctly(nested_server: str):
"""Test patch for https://github.com/modelcontextprotocol/python-sdk/pull/659"""
- async with Client(transport=StreamableHttpTransport(nested_server)) as client:
+ async with Client(
+ transport=StreamableHttpTransport(nested_server), mode="legacy"
+ ) as client:
result = await client.ping()
assert result is True
@@ -265,11 +269,14 @@ async def test_nested_streamable_http_server_resolves_correctly(nested_server: s
class TestTimeout:
async def test_timeout(self, streamable_http_server: str):
# note this transport behaves differently than others and raises
- # MCPError from the *client* context
+ # MCPError from the *client* context. Pinned to legacy: on a modern
+ # (server/discover) connection a connect-time timeout surfaces as a raw
+ # httpx.ReadTimeout from the probe rather than a wrapped MCPError.
with pytest.raises(MCPError, match="timed out"):
async with Client(
transport=StreamableHttpTransport(streamable_http_server),
timeout=0.02,
+ mode="legacy",
) as client:
await client.call_tool("sleep", {"seconds": 0.05})
diff --git a/tests/client/transports/test_memory_transport.py b/tests/client/transports/test_memory_transport.py
index e5eab41cd..a67784c89 100644
--- a/tests/client/transports/test_memory_transport.py
+++ b/tests/client/transports/test_memory_transport.py
@@ -47,7 +47,7 @@ async def test_task_teardown_does_not_hang():
t0 = time.monotonic()
- async with Client(mcp) as client:
+ async with Client(mcp, mode="legacy") as client:
task = await client.call_tool("fast_tool", {"x": 21}, task=True)
result = await task.result()
assert result.data == 42
diff --git a/tests/server/test_protocol_eras.py b/tests/server/test_protocol_eras.py
index ca9a8ac41..d248c69ed 100644
--- a/tests/server/test_protocol_eras.py
+++ b/tests/server/test_protocol_eras.py
@@ -567,7 +567,7 @@ async def test_task_submission_and_get_on_legacy_latest(task_server):
`task=` parameter (verified: mcp.client.session.ClientSession.call_tool
exposes no task metadata arg) — see item below.
"""
- async with FastMCPClient(task_server) as client:
+ async with FastMCPClient(task_server, mode="legacy") as client:
assert client.initialize_result is not None
assert client.initialize_result.protocol_version == "2025-11-25"
From 93fe4e89e0272fb9fe3472b7ce363a6be0fc57e2 Mon Sep 17 00:00:00 2001
From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com>
Date: Thu, 9 Jul 2026 10:44:47 -0400
Subject: [PATCH 3/4] Document client extensions and mode=auto default
---
docs/clients/client.mdx | 39 ++++++++++++++++---
docs/development/v4-notes/change-register.mdx | 23 ++++++++++-
2 files changed, 55 insertions(+), 7 deletions(-)
diff --git a/docs/clients/client.mdx b/docs/clients/client.mdx
index 30e09e613..bc0219bd7 100644
--- a/docs/clients/client.mdx
+++ b/docs/clients/client.mdx
@@ -166,19 +166,19 @@ async with client:
MCP has two protocol eras: the original *legacy* era, which begins every connection with an `initialize` handshake, and the *modern* era (protocol version `2026-07-28` and later), which a client discovers by probing the server's `server/discover` endpoint. The `mode` parameter controls which era the client negotiates when it connects.
-By default, `mode="legacy"`. This runs the initialize handshake and behaves identically to earlier FastMCP versions, so existing code connecting to any server keeps working unchanged.
+By default, `mode="auto"`. The client probes `server/discover` and adopts the modern protocol when the server responds; for any server that is not positive evidence of modern support, it falls back to the legacy handshake. This makes the default safe against a mixed fleet of legacy and modern servers.
```python
from fastmcp import Client
-# Legacy handshake (the default)
-client = Client("https://example.com/mcp", mode="legacy")
+# Negotiate the newest era the server supports (the default)
+client = Client("https://example.com/mcp", mode="auto")
```
-Set `mode="auto"` to negotiate the newest era the server supports. The client probes `server/discover` and adopts the modern protocol when the server responds; for any server that is not positive evidence of modern support, it falls back to the legacy handshake. This makes `"auto"` safe to use against a mixed fleet of legacy and modern servers.
+Set `mode="legacy"` to force the initialize handshake. This behaves identically to earlier FastMCP versions and is the opt-out if a server misbehaves under discovery or you need the legacy `initialize` result object.
```python
-client = Client("https://example.com/mcp", mode="auto")
+client = Client("https://example.com/mcp", mode="legacy")
```
You can also pin a specific modern protocol version to adopt it directly, without a discovery probe:
@@ -196,7 +196,9 @@ async with Client("https://example.com/mcp", mode="auto") as client:
```
-`mode="auto"` is not the default yet — the conservative `"legacy"` remains the default to preserve byte-identical behavior against pre-2026 servers. Whether `"auto"` becomes the default is a future release decision.
+`mode="auto"` is the default as of FastMCP 4.0. Earlier versions defaulted to `"legacy"`. If a server behaves unexpectedly under discovery, or you depend on the legacy `initialize` result, pin the old behavior with `Client(..., mode="legacy")`.
+
+The SSE transport is legacy-only — it cannot carry the sessionless modern era — so a client connecting over SSE always negotiates the legacy handshake, even under `mode="auto"`.
## Response caching
@@ -234,6 +236,31 @@ async with client:
fresh = await client.list_tools_mcp(cache_mode="refresh")
```
+## Client extensions
+
+
+
+Client extensions (SEP-2133) are the advanced mechanism a client uses to opt into vendor capabilities that live outside the core protocol. An extension is a `ClientExtension` instance that bundles three things: a capability *advertisement* the server can read, one or more *result claims* that let the client parse extra `tools/call` result shapes, and *notification bindings* that observe server notifications the core protocol doesn't define. Pass a sequence of them to `extensions=`.
+
+```python
+from fastmcp import Client
+from myproject.extensions import AppsExtension
+
+client = Client("https://example.com/mcp", extensions=[AppsExtension()])
+```
+
+Each extension's contributions are threaded into the underlying session. Notification bindings compose with FastMCP's own internal task-status binding rather than replacing it, so an extension that observes a custom notification and FastMCP's task tracking both work on the same connection. Result claims and their advertisements are honored only on modern-era connections, so they are inert on a legacy handshake.
+
+For the rare case where you need to register additional result claims against an extension that is already advertised, pass them through `result_claims=`, keyed by the extension's identifier. Prefer declaring claims on the extension itself; this parameter merges extra claims with an extension's own.
+
+```python
+client = Client(
+ "https://example.com/mcp",
+ extensions=[AppsExtension()],
+ result_claims={"example.com/apps": [extra_claim]},
+)
+```
+
## Operations
FastMCP clients interact with three types of server components.
diff --git a/docs/development/v4-notes/change-register.mdx b/docs/development/v4-notes/change-register.mdx
index 6476d96cd..2ad00173a 100644
--- a/docs/development/v4-notes/change-register.mdx
+++ b/docs/development/v4-notes/change-register.mdx
@@ -160,7 +160,28 @@ Resource-not-found responses from the core `resources/read` handler previously u
## Client
-The `fastmcp.Client` public API is preserved exactly. The client stays a wrapper around `mcp.ClientSession` in legacy/handshake mode; the first-class `mcp.client.Client` is deliberately not adopted in this PR.
+The `fastmcp.Client` public API is largely preserved. The client stays a wrapper around `mcp.ClientSession`; the first-class `mcp.client.Client` is deliberately not adopted in this PR. Two client-surface changes are called out below: the connection `mode` default flips to `"auto"`, and `extensions=` / `result_claims=` are newly surfaced.
+
+### Connection `mode` defaults to `"auto"` — Breaking (behavior)
+
+`Client(mode=...)` now defaults to `"auto"` instead of `"legacy"`. The client probes `server/discover` and adopts the modern (`2026-07-28`) era when the server responds, denylist-falling-back to the initialize handshake for any server that is not positive evidence of a modern peer. Against a FastMCP server (which serves both eras), an ordinary `Client(url)` now negotiates the modern era by default, where the legacy-only Context push features are unavailable per the per-feature era matrix (see the *Protocol eras* section below) — server-initiated sampling/elicitation/roots, `ping`, session ids, and FastMCP task submission all require the legacy era. The one-line revert is `Client(..., mode="legacy")`, which restores byte-identical pre-v4 negotiation.
+
+The SSE transport is legacy-only (it cannot carry the sessionless modern era), so a client connecting over SSE negotiates the legacy handshake even under `mode="auto"` — expressed by a `ClientTransport.legacy_only` flag set on `SSETransport`.
+
+```python
+from fastmcp import Client
+
+client = Client("https://example.com/mcp") # now negotiates "auto"
+client = Client("https://example.com/mcp", mode="legacy") # opt back into the handshake
+```
+
+*Verify:* `fastmcp_slim/fastmcp/client/client.py` (`mode` default, `_negotiate` `legacy_only` shortcut), `fastmcp_slim/fastmcp/client/transports/{base,sse}.py` (`legacy_only`), `tests/client/client/test_mode_negotiation.py` (default, clean discover-rejection fallback, legacy-only transport), `docs/clients/client.mdx`.
+
+### `extensions=` / `result_claims=` surfaced — New (opt-in feature)
+
+`fastmcp.Client` now accepts `extensions=` (a sequence of SEP-2133 `ClientExtension` instances) and `result_claims=` (extra `ResultClaim`s keyed by an advertised extension's identifier). Each extension's capability advertisement, result claims, and notification bindings are folded into the underlying `ClientSession` on every transport. User-supplied notification bindings **compose** with FastMCP's internal task-status binding rather than clobbering it: the task binding always leads, and a user extension that binds the same method surfaces a clear duplicate-method error at connect time rather than silently winning. Claimed shapes are modern-only, so they are inert on a legacy connection.
+
+*Verify:* `fastmcp_slim/fastmcp/client/client.py` (`_fold_extensions`, `_build_extension_kwargs`, `new()`), `fastmcp_slim/fastmcp/client/transports/base.py` (`SessionKwargs.extensions`/`result_claims`), `tests/client/test_client_extensions.py` (fold, composition, live both-bindings-fire).
### Transports yield 2-tuples — Absorbed
From 7d6c36a85d90067ee691eef5b28200c7bf185965 Mon Sep 17 00:00:00 2001
From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com>
Date: Thu, 9 Jul 2026 11:14:16 -0400
Subject: [PATCH 4/4] Pin legacy for handshake-era features after mode=auto
flip
---
docs/development/v4-notes/change-register.mdx | 4 +-
.../fastmcp/client/transports/config.py | 5 +
fastmcp_slim/fastmcp/mcp_config.py | 4 +-
.../fastmcp/server/providers/proxy.py | 6 +
fastmcp_slim/fastmcp/utilities/inspect.py | 9 +-
tests/client/test_openapi.py | 6 +-
tests/deprecated/test_elicitation.py | 4 +-
.../test_initialization_middleware.py | 22 ++--
tests/server/middleware/test_middleware.py | 4 +-
.../middleware/test_middleware_nested.py | 2 +-
tests/server/middleware/test_ping.py | 4 +-
.../providers/proxy/test_proxy_client.py | 57 ++++++---
.../providers/proxy/test_proxy_server.py | 120 +++++++++---------
.../proxy/test_stateful_proxy_client.py | 16 ++-
.../tasks/test_concurrent_dependencies.py | 16 +--
.../tasks/test_context_background_task.py | 32 ++---
.../tasks/test_custom_subclass_tasks.py | 10 +-
tests/server/tasks/test_notifications.py | 5 +-
.../server/tasks/test_progress_dependency.py | 10 +-
.../test_resource_task_meta_parameter.py | 16 +--
.../tasks/test_server_tasks_parameter.py | 26 ++--
tests/server/tasks/test_snapshot_restore.py | 6 +-
tests/server/tasks/test_task_capabilities.py | 4 +-
tests/server/tasks/test_task_config.py | 30 ++---
tests/server/tasks/test_task_dependencies.py | 18 +--
.../tasks/test_task_elicitation_relay.py | 16 +--
.../server/tasks/test_task_meta_parameter.py | 20 +--
tests/server/tasks/test_task_metadata.py | 6 +-
tests/server/tasks/test_task_methods.py | 20 +--
tests/server/tasks/test_task_mount.py | 84 ++++++------
tests/server/tasks/test_task_prompts.py | 8 +-
tests/server/tasks/test_task_protocol.py | 6 +-
tests/server/tasks/test_task_proxy.py | 20 +--
tests/server/tasks/test_task_resources.py | 10 +-
tests/server/tasks/test_task_return_types.py | 32 ++---
tests/server/tasks/test_task_security.py | 16 +--
.../tasks/test_task_status_notifications.py | 14 +-
tests/server/tasks/test_task_tools.py | 16 +--
tests/server/tasks/test_task_ttl.py | 6 +-
.../server/telemetry/test_sampling_tracing.py | 8 +-
tests/server/telemetry/test_server_tracing.py | 4 +-
tests/server/test_icons.py | 8 +-
tests/server/test_session_visibility.py | 52 ++++----
tests/server/test_tool_annotations.py | 2 +-
tests/server/transforms/test_search.py | 2 +-
tests/test_apps.py | 4 +-
tests/test_compat.py | 2 +-
.../tool_transform/test_tool_transform.py | 2 +-
48 files changed, 423 insertions(+), 371 deletions(-)
diff --git a/docs/development/v4-notes/change-register.mdx b/docs/development/v4-notes/change-register.mdx
index 2ad00173a..81fb21359 100644
--- a/docs/development/v4-notes/change-register.mdx
+++ b/docs/development/v4-notes/change-register.mdx
@@ -166,7 +166,7 @@ The `fastmcp.Client` public API is largely preserved. The client stays a wrapper
`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`.
+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
@@ -175,7 +175,7 @@ client = Client("https://example.com/mcp") # now negotiates "au
client = Client("https://example.com/mcp", mode="legacy") # opt back into the handshake
```
-*Verify:* `fastmcp_slim/fastmcp/client/client.py` (`mode` default, `_negotiate` `legacy_only` shortcut), `fastmcp_slim/fastmcp/client/transports/{base,sse}.py` (`legacy_only`), `tests/client/client/test_mode_negotiation.py` (default, clean discover-rejection fallback, legacy-only transport), `docs/clients/client.mdx`.
+*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)
diff --git a/fastmcp_slim/fastmcp/client/transports/config.py b/fastmcp_slim/fastmcp/client/transports/config.py
index 472e94b86..6a71ea83c 100644
--- a/fastmcp_slim/fastmcp/client/transports/config.py
+++ b/fastmcp_slim/fastmcp/client/transports/config.py
@@ -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)
diff --git a/fastmcp_slim/fastmcp/mcp_config.py b/fastmcp_slim/fastmcp/mcp_config.py
index 286e34c56..9737b48b1 100644
--- a/fastmcp_slim/fastmcp/mcp_config.py
+++ b/fastmcp_slim/fastmcp/mcp_config.py
@@ -137,7 +137,9 @@ class _TransformingMCPServerMixin(BaseModel):
) from exc
transport = cast("ClientTransport", super().to_transport()) # ty: ignore[unresolved-attribute]
- client = Client(transport=transport, name=client_name)
+ # The proxy that wraps this client forwards the initialize handshake and
+ # server-initiated features, which require the legacy era.
+ client = Client(transport=transport, name=client_name, mode="legacy")
wrapped_mcp_server = create_proxy(client, name=server_name)
if self.include_tags is not None:
diff --git a/fastmcp_slim/fastmcp/server/providers/proxy.py b/fastmcp_slim/fastmcp/server/providers/proxy.py
index fd0817af1..1126ce3a0 100644
--- a/fastmcp_slim/fastmcp/server/providers/proxy.py
+++ b/fastmcp_slim/fastmcp/server/providers/proxy.py
@@ -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]
diff --git a/fastmcp_slim/fastmcp/utilities/inspect.py b/fastmcp_slim/fastmcp/utilities/inspect.py
index b9e3d0827..1ce403643 100644
--- a/fastmcp_slim/fastmcp/utilities/inspect.py
+++ b/fastmcp_slim/fastmcp/utilities/inspect.py
@@ -257,8 +257,9 @@ async def inspect_fastmcp_v1(mcp: SDKServer) -> FastMCPInfo:
Returns:
FastMCPInfo dataclass containing the extracted information
"""
- # Use a client to interact with the SDK's high-level MCPServer
- async with Client(mcp) as client:
+ # Inspection reads the full server_info (icons, website_url) that only the
+ # legacy initialize handshake carries, so pin the handshake era.
+ async with Client(mcp, mode="legacy") as client:
# Get components via client calls (these return MCP objects)
mcp_tools = await client.list_tools()
mcp_prompts = await client.list_prompts()
@@ -467,7 +468,9 @@ async def format_mcp_info(mcp: FastMCP[Any] | SDKServer) -> bytes:
Uses Client to get the standard MCP protocol format with camelCase fields.
Includes version metadata at the top level.
"""
- async with Client(mcp) as client:
+ # Inspection reads the full server_info that only the legacy initialize
+ # handshake carries, so pin the handshake era.
+ async with Client(mcp, mode="legacy") as client:
# Get all the MCP protocol objects
tools_result = await client.list_tools_mcp()
prompts_result = await client.list_prompts_mcp()
diff --git a/tests/client/test_openapi.py b/tests/client/test_openapi.py
index 236e6efb3..eaaecb8a8 100644
--- a/tests/client/test_openapi.py
+++ b/tests/client/test_openapi.py
@@ -196,7 +196,11 @@ async def test_client_headers_proxy(proxy_server: str):
"""
Test that client headers are passed through the proxy to the remove server.
"""
- async with Client(transport=StreamableHttpTransport(proxy_server)) as client:
+ # The proxy backend forwards over the legacy handshake, so align the outer
+ # client's era with it.
+ async with Client(
+ transport=StreamableHttpTransport(proxy_server), mode="legacy"
+ ) as client:
result = await client.read_resource("resource://get_headers_headers_get")
assert isinstance(result[0], TextResourceContents)
headers = json.loads(result[0].text)
diff --git a/tests/deprecated/test_elicitation.py b/tests/deprecated/test_elicitation.py
index d86e549d8..cbb35541c 100644
--- a/tests/deprecated/test_elicitation.py
+++ b/tests/deprecated/test_elicitation.py
@@ -25,5 +25,7 @@ async def test_elicitation_none_response_type_warns_deprecation():
async def elicitation_handler(message, response_type, params, ctx):
return ElicitResult(action="accept", content={})
- async with Client(mcp, elicitation_handler=elicitation_handler) as client:
+ async with Client(
+ mcp, mode="legacy", elicitation_handler=elicitation_handler
+ ) as client:
await client.call_tool("my_tool", {})
diff --git a/tests/server/middleware/test_initialization_middleware.py b/tests/server/middleware/test_initialization_middleware.py
index 3a077b367..a05727d04 100644
--- a/tests/server/middleware/test_initialization_middleware.py
+++ b/tests/server/middleware/test_initialization_middleware.py
@@ -123,7 +123,7 @@ async def test_simple_initialization_hook():
server.add_middleware(middleware)
# Connect client
- async with Client(server):
+ async with Client(server, mode="legacy"):
# Middleware should have been called
assert middleware.called is True, "on_initialize was not called"
@@ -139,7 +139,7 @@ async def test_middleware_receives_initialization():
return f"Result: {x}"
# Connect client
- async with Client(server) as client:
+ async with Client(server, mode="legacy") as client:
# Middleware should have been called during initialization
assert middleware.initialized is True
@@ -160,7 +160,7 @@ async def test_client_detection_middleware():
return "example"
# Connect with a client
- async with Client(server) as client:
+ async with Client(server, mode="legacy") as client:
# Middleware should have been called during initialization
assert middleware.initialization_called is True
assert middleware.is_test_client is True
@@ -190,7 +190,7 @@ async def test_multiple_middleware_initialization():
def test_tool() -> str:
return "test"
- async with Client(server) as client:
+ async with Client(server, mode="legacy") as client:
# Both middleware should have processed initialization
assert init_mw.initialized is True
assert detect_mw.initialization_called is True
@@ -241,7 +241,7 @@ async def test_session_state_persists_across_tool_calls():
def test_tool() -> str:
return "success"
- async with Client(server) as client:
+ async with Client(server, mode="legacy") as client:
# First call - state should be None initially
result = await client.call_tool("test_tool", {})
assert isinstance(result.content[0], TextContent)
@@ -287,7 +287,7 @@ async def test_middleware_can_access_initialize_result():
middleware = ResponseCapturingMiddleware()
server.add_middleware(middleware)
- async with Client(server):
+ async with Client(server, mode="legacy"):
# Middleware should have captured the InitializeResult
assert middleware.initialize_result is not None
assert isinstance(middleware.initialize_result, mt.InitializeResult)
@@ -315,7 +315,7 @@ async def test_middleware_mcp_error_during_initialization():
server.add_middleware(ErrorThrowingMiddleware())
with pytest.raises(MCPError) as exc_info:
- async with Client(server):
+ async with Client(server, mode="legacy"):
pass
assert exc_info.value.error.message == "Invalid initialization parameters"
@@ -337,7 +337,7 @@ async def test_middleware_mcp_error_before_call_next():
server.add_middleware(EarlyErrorMiddleware())
with pytest.raises(MCPError) as exc_info:
- async with Client(server):
+ async with Client(server, mode="legacy"):
pass
assert exc_info.value.error.message == "Request validation failed"
@@ -370,7 +370,7 @@ async def test_middleware_mcp_error_after_call_next():
server.add_middleware(middleware)
# Error is logged but not re-raised to prevent duplicate response
- async with Client(server):
+ async with Client(server, mode="legacy"):
pass
assert middleware.error_raised is True
@@ -403,7 +403,7 @@ async def test_state_isolation_between_streamable_http_clients():
# Client 1 stores its value
transport1 = StreamableHttpTransport(url=url)
- async with Client(transport=transport1) as client1:
+ async with Client(transport=transport1, mode="legacy") as client1:
result1 = await client1.call_tool(
"store_and_read", {"value": "client1-value"}
)
@@ -414,7 +414,7 @@ async def test_state_isolation_between_streamable_http_clients():
# Client 2 should have completely isolated state
transport2 = StreamableHttpTransport(url=url)
- async with Client(transport=transport2) as client2:
+ async with Client(transport=transport2, mode="legacy") as client2:
result2 = await client2.call_tool(
"store_and_read", {"value": "client2-value"}
)
diff --git a/tests/server/middleware/test_middleware.py b/tests/server/middleware/test_middleware.py
index f58736437..8518845b7 100644
--- a/tests/server/middleware/test_middleware.py
+++ b/tests/server/middleware/test_middleware.py
@@ -173,7 +173,7 @@ class TestMiddlewareHooks:
async def test_call_tool(
self, mcp_server: FastMCP, recording_middleware: RecordingMiddleware
):
- async with Client(mcp_server) as client:
+ async with Client(mcp_server, mode="legacy") as client:
await client.call_tool("add", {"a": 1, "b": 2})
assert recording_middleware.assert_called(at_least=9)
@@ -299,7 +299,7 @@ class TestMiddlewareHooks:
async def test_initialize(
self, mcp_server: FastMCP, recording_middleware: RecordingMiddleware
):
- async with Client(mcp_server) as client:
+ async with Client(mcp_server, mode="legacy") as client:
await client.ping()
assert recording_middleware.assert_called(at_least=1)
diff --git a/tests/server/middleware/test_middleware_nested.py b/tests/server/middleware/test_middleware_nested.py
index cc2eea6c0..a67b78830 100644
--- a/tests/server/middleware/test_middleware_nested.py
+++ b/tests/server/middleware/test_middleware_nested.py
@@ -477,7 +477,7 @@ class TestProxyServer:
# proxy server will have its tools listed as well as called in order to
# apply transforms and filters prior to the call.
proxy_server = create_proxy(mcp_server, name="Proxy Server")
- async with Client(proxy_server) as client:
+ async with Client(proxy_server, mode="legacy") as client:
await client.call_tool("add", {"a": 1, "b": 2})
assert recording_middleware.assert_called(at_least=6)
diff --git a/tests/server/middleware/test_ping.py b/tests/server/middleware/test_ping.py
index b35616fc1..4713ca7a5 100644
--- a/tests/server/middleware/test_ping.py
+++ b/tests/server/middleware/test_ping.py
@@ -193,7 +193,7 @@ class TestPingMiddlewareIntegration:
assert len(middleware._active_sessions) == 0
- async with Client(mcp) as client:
+ async with Client(mcp, mode="legacy") as client:
result = await client.call_tool("hello")
assert result.content[0].text == "Hello!"
@@ -214,7 +214,7 @@ class TestPingMiddlewareIntegration:
def hello() -> str:
return "Hello!"
- async with Client(mcp) as client:
+ async with Client(mcp, mode="legacy") as client:
await client.call_tool("hello")
# Should have one active session
assert len(middleware._active_sessions) == 1
diff --git a/tests/server/providers/proxy/test_proxy_client.py b/tests/server/providers/proxy/test_proxy_client.py
index 216d283e8..2fca6fd51 100644
--- a/tests/server/providers/proxy/test_proxy_client.py
+++ b/tests/server/providers/proxy/test_proxy_client.py
@@ -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
diff --git a/tests/server/providers/proxy/test_proxy_server.py b/tests/server/providers/proxy/test_proxy_server.py
index 762a09abc..6d85f9712 100644
--- a/tests/server/providers/proxy/test_proxy_server.py
+++ b/tests/server/providers/proxy/test_proxy_server.py
@@ -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
diff --git a/tests/server/providers/proxy/test_stateful_proxy_client.py b/tests/server/providers/proxy/test_stateful_proxy_client.py
index 3ebb9d536..f9389d608 100644
--- a/tests/server/providers/proxy/test_stateful_proxy_client.py
+++ b/tests/server/providers/proxy/test_stateful_proxy_client.py
@@ -95,8 +95,12 @@ class TestStatefulProxyClient:
results["logger_b"] = message
async with (
- Client(stateful_proxy_server, log_handler=log_handler_a) as client_a,
- Client(stateful_proxy_server, log_handler=log_handler_b) as client_b,
+ Client(
+ stateful_proxy_server, mode="legacy", log_handler=log_handler_a
+ ) as client_a,
+ Client(
+ stateful_proxy_server, mode="legacy", log_handler=log_handler_b
+ ) as client_b,
):
async with create_task_group() as tg:
tg.start_soon(
@@ -115,7 +119,7 @@ class TestStatefulProxyClient:
async def test_stateful_proxy(self, stateful_proxy_server: FastMCP):
"""Test that the state shared across multiple calls for the same client (fixes #959)."""
- async with Client(stateful_proxy_server) as client:
+ async with Client(stateful_proxy_server, mode="legacy") as client:
with pytest.raises(ToolError, match="Value not found"):
await client.call_tool("stateful_get", {})
@@ -126,7 +130,7 @@ class TestStatefulProxyClient:
async def test_stateless_proxy(self, stateless_server: str):
"""Test that the state will not be shared across different calls,
even if they are from the same client."""
- async with Client(stateless_server) as client:
+ async with Client(stateless_server, mode="legacy") as client:
await client.call_tool("stateful_put", {"value": 1})
with pytest.raises(ToolError, match="Value not found"):
@@ -154,7 +158,7 @@ class TestStatefulProxyClient:
multi_proxy_mcp.mount(proxy_mcp_a, namespace="a")
multi_proxy_mcp.mount(proxy_mcp_b, namespace="b")
- async with Client(multi_proxy_mcp) as client:
+ async with Client(multi_proxy_mcp, mode="legacy") as client:
result_a = await client.call_tool("a_tool_a", {})
result_b = await client.call_tool("b_tool_b", {})
assert result_a.data == "a"
@@ -202,7 +206,7 @@ class TestStatefulProxyClient:
# related_request_id routing for server-initiated messages.
async with run_server_async(proxy) as proxy_url:
async with Client(
- proxy_url, elicitation_handler=elicitation_handler
+ proxy_url, mode="legacy", elicitation_handler=elicitation_handler
) as client:
result1 = await client.call_tool("ask_name", {})
assert result1.data == "Hello, Alice!"
diff --git a/tests/server/tasks/test_concurrent_dependencies.py b/tests/server/tasks/test_concurrent_dependencies.py
index eb5af1bb1..edc3b6a6a 100644
--- a/tests/server/tasks/test_concurrent_dependencies.py
+++ b/tests/server/tasks/test_concurrent_dependencies.py
@@ -30,7 +30,7 @@ async def test_concurrent_foreground_tools_with_context():
results.append(name)
return f"done:{name}"
- async with Client(mcp) as client:
+ async with Client(mcp, mode="legacy") as client:
tasks = [client.call_tool("slow_tool", {"name": f"task-{i}"}) for i in range(4)]
outcomes = await asyncio.gather(*tasks)
@@ -56,7 +56,7 @@ async def test_concurrent_foreground_tools_with_progress():
await progress.increment()
return f"done:{name}"
- async with Client(mcp) as client:
+ async with Client(mcp, mode="legacy") as client:
tasks = [
client.call_tool(
"variable_tool", {"name": f"t-{i}", "delay": 0.01 * (i + 1)}
@@ -80,7 +80,7 @@ async def test_concurrent_background_tasks_with_context():
await asyncio.sleep(0.05)
return f"bg:{name}"
- async with Client(mcp) as client:
+ async with Client(mcp, mode="legacy") as client:
task_handles = [
await client.call_tool("bg_tool", {"name": f"bg-{i}"}, task=True)
for i in range(4)
@@ -109,7 +109,7 @@ async def test_concurrent_background_tasks_with_progress():
await progress.increment()
return f"bg:{name}"
- async with Client(mcp) as client:
+ async with Client(mcp, mode="legacy") as client:
task_handles = [
await client.call_tool(
"bg_progress_tool",
@@ -137,7 +137,7 @@ async def test_dependency_aenter_returns_fresh_instances():
instances.append(ctx)
return "ok"
- async with Client(mcp) as client:
+ async with Client(mcp, mode="legacy") as client:
await asyncio.gather(
client.call_tool("capture_context", {}),
client.call_tool("capture_context", {}),
@@ -161,7 +161,7 @@ async def test_progress_aenter_returns_fresh_instances():
await progress.increment()
return "ok"
- async with Client(mcp) as client:
+ async with Client(mcp, mode="legacy") as client:
await asyncio.gather(
client.call_tool("capture_progress", {}),
client.call_tool("capture_progress", {}),
@@ -187,7 +187,7 @@ async def test_sync_context_functions_work_in_background_without_deps():
headers = get_http_headers()
return {"has_headers": str(bool(headers))}
- async with Client(mcp) as client:
+ async with Client(mcp, mode="legacy") as client:
task = await client.call_tool("bare_sync_access", {}, task=True)
result = await task.result()
assert result.data == {"has_headers": "False"}
@@ -207,7 +207,7 @@ async def test_sync_context_functions_work_in_background_with_context():
"is_background": str(ctx.is_background_task),
}
- async with Client(mcp) as client:
+ async with Client(mcp, mode="legacy") as client:
task = await client.call_tool("context_sync_access", {}, task=True)
result = await task.result()
assert result.data["is_background"] == "True"
diff --git a/tests/server/tasks/test_context_background_task.py b/tests/server/tasks/test_context_background_task.py
index e0b8b3dc3..a81a293f7 100644
--- a/tests/server/tasks/test_context_background_task.py
+++ b/tests/server/tasks/test_context_background_task.py
@@ -1,7 +1,7 @@
"""Tests for Context background task support (SEP-1686).
Tests Context API surface (unit) and background task elicitation (integration).
-Integration tests use Client(mcp) with the real memory:// Docket backend —
+Integration tests use Client(mcp, mode="legacy") with the real memory:// Docket backend —
no mocking of Redis, Docket, or session internals.
"""
@@ -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"
diff --git a/tests/server/tasks/test_custom_subclass_tasks.py b/tests/server/tasks/test_custom_subclass_tasks.py
index 2077e066c..80233e8fc 100644
--- a/tests/server/tasks/test_custom_subclass_tasks.py
+++ b/tests/server/tasks/test_custom_subclass_tasks.py
@@ -67,14 +67,14 @@ def custom_tool_server():
async def test_custom_tool_sync_execution(custom_tool_server):
"""Custom tool executes synchronously when no task metadata."""
- async with Client(custom_tool_server) as client:
+ async with Client(custom_tool_server, mode="legacy") as client:
result = await client.call_tool("custom_tool", {})
assert "Custom tool executed" in str(result)
async def test_custom_tool_background_execution(custom_tool_server):
"""Custom tool executes as background task when task=True."""
- async with Client(custom_tool_server) as client:
+ async with Client(custom_tool_server, mode="legacy") as client:
task = await client.call_tool("custom_tool", {}, task=True)
assert task is not None
@@ -88,7 +88,7 @@ async def test_custom_tool_background_execution(custom_tool_server):
async def test_custom_tool_with_arguments(custom_tool_server):
"""Custom tool receives arguments correctly in background execution."""
- async with Client(custom_tool_server) as client:
+ async with Client(custom_tool_server, mode="legacy") as client:
task = await client.call_tool("custom_logic", {"duration": 1}, task=True)
assert task is not None
@@ -98,7 +98,7 @@ async def test_custom_tool_with_arguments(custom_tool_server):
async def test_custom_tool_forbidden_sync_only(custom_tool_server):
"""Custom tool with forbidden mode executes sync only."""
- async with Client(custom_tool_server) as client:
+ async with Client(custom_tool_server, mode="legacy") as client:
# Sync execution works
result = await client.call_tool("custom_forbidden", {})
assert "Sync only" in str(result)
@@ -106,7 +106,7 @@ async def test_custom_tool_forbidden_sync_only(custom_tool_server):
async def test_custom_tool_forbidden_rejects_task(custom_tool_server):
"""Custom tool with forbidden mode returns error for task request."""
- async with Client(custom_tool_server) as client:
+ async with Client(custom_tool_server, mode="legacy") as client:
task = await client.call_tool("custom_forbidden", {}, task=True)
# Should return immediately with error
diff --git a/tests/server/tasks/test_notifications.py b/tests/server/tasks/test_notifications.py
index ca7abfe1a..a2908c0b2 100644
--- a/tests/server/tasks/test_notifications.py
+++ b/tests/server/tasks/test_notifications.py
@@ -1,7 +1,7 @@
"""Tests for distributed notification queue (SEP-1686).
Integration tests verify that the notification queue works end-to-end
-using Client(mcp) with the real memory:// Docket backend.
+using Client(mcp, mode="legacy") with the real memory:// Docket backend.
No mocking of Redis, sessions, or Docket internals.
"""
@@ -53,6 +53,7 @@ class TestNotificationIntegration:
async with Client(
mcp,
+ mode="legacy",
elicitation_handler=elicitation_handler,
) as client:
task = await client.call_tool("elicit_tool", {}, task=True)
@@ -107,7 +108,7 @@ class TestNotificationIntegration:
count_before = get_subscriber_count()
- async with Client(mcp) as client:
+ async with Client(mcp, mode="legacy") as client:
task = await client.call_tool("lifecycle_tool", {}, task=True)
await asyncio.wait_for(tool_started.wait(), timeout=5.0)
diff --git a/tests/server/tasks/test_progress_dependency.py b/tests/server/tasks/test_progress_dependency.py
index 6cc35996a..2370b6e34 100644
--- a/tests/server/tasks/test_progress_dependency.py
+++ b/tests/server/tasks/test_progress_dependency.py
@@ -16,7 +16,7 @@ async def test_progress_in_immediate_execution():
await progress.set_message("Testing")
return "done"
- async with Client(mcp) as client:
+ async with Client(mcp, mode="legacy") as client:
result = await client.call_tool("test_tool", {})
from mcp_types import TextContent
@@ -35,7 +35,7 @@ async def test_progress_in_background_task():
await progress.set_message("Step 1")
return "done"
- async with Client(mcp) as client:
+ async with Client(mcp, mode="legacy") as client:
task = await client.call_tool("test_task", {}, task=True)
result = await task.result()
from mcp_types import TextContent
@@ -55,7 +55,7 @@ async def test_progress_tracks_multiple_increments():
await progress.increment()
return "counted"
- async with Client(mcp) as client:
+ async with Client(mcp, mode="legacy") as client:
result = await client.call_tool("count_to_ten", {})
from mcp_types import TextContent
@@ -86,7 +86,7 @@ async def test_progress_status_message_in_background_task():
await progress.increment()
return "done"
- async with Client(mcp) as client:
+ async with Client(mcp, mode="legacy") as client:
task = await client.call_tool("task_with_progress", {}, task=True)
# Wait for first step to start
@@ -141,7 +141,7 @@ async def test_inmemory_progress_state():
"message": progress.message,
}
- async with Client(mcp) as client:
+ async with Client(mcp, mode="legacy") as client:
result = await client.call_tool("test_tool", {})
from mcp_types import TextContent
diff --git a/tests/server/tasks/test_resource_task_meta_parameter.py b/tests/server/tasks/test_resource_task_meta_parameter.py
index 86d36b3a4..1a5caeffd 100644
--- a/tests/server/tasks/test_resource_task_meta_parameter.py
+++ b/tests/server/tasks/test_resource_task_meta_parameter.py
@@ -124,7 +124,7 @@ class TestResourceTaskMetaClientIntegration:
async def immediate_resource() -> str:
return "hello"
- async with Client(server) as client:
+ async with Client(server, mode="legacy") as client:
result = await client.read_resource("data://test")
# Should get ReadResourceResult directly
@@ -138,7 +138,7 @@ class TestResourceTaskMetaClientIntegration:
async def task_resource() -> str:
return "hello"
- async with Client(server) as client:
+ async with Client(server, mode="legacy") as client:
from fastmcp.client.tasks import ResourceTask
task = await client.read_resource("data://test", task=True)
@@ -157,7 +157,7 @@ class TestResourceTaskMetaClientIntegration:
async def get_item(id: str) -> str:
return f"Item {id}"
- async with Client(server) as client:
+ async with Client(server, mode="legacy") as client:
from fastmcp.client.tasks import ResourceTask
task = await client.read_resource("item://42", task=True)
@@ -187,7 +187,7 @@ class TestResourceTaskMetaDirectServerCall:
# Should get CreateTaskResult since we provided task_meta
return f"Created task: {result.task.task_id}"
- async with Client(server) as client:
+ async with Client(server, mode="legacy") as client:
result = await client.call_tool("outer_tool", {})
assert "Created task:" in str(result)
@@ -206,7 +206,7 @@ class TestResourceTaskMetaDirectServerCall:
# Should get ResourceResult directly
return f"Got result: {result.contents[0].content}"
- async with Client(server) as client:
+ async with Client(server, mode="legacy") as client:
result = await client.call_tool("outer_tool", {})
assert "Got result: inner data" in str(result)
@@ -223,7 +223,7 @@ class TestResourceTaskMetaDirectServerCall:
result = await server.read_resource("item://99", task_meta=TaskMeta())
return f"Created task: {result.task.task_id}"
- async with Client(server) as client:
+ async with Client(server, mode="legacy") as client:
result = await client.call_tool("outer_tool", {})
assert "Created task:" in str(result)
@@ -243,7 +243,7 @@ class TestResourceTaskMetaDirectServerCall:
)
return f"Task TTL: {result.task.ttl}"
- async with Client(server) as client:
+ async with Client(server, mode="legacy") as client:
result = await client.call_tool("outer_tool", {})
assert "Task TTL: 45000" in str(result)
@@ -274,7 +274,7 @@ class TestResourceTaskMetaTypeNarrowing:
async def task_resource() -> str:
return "hello"
- async with Client(server) as client:
+ async with Client(server, mode="legacy") as client:
# Need to use client to get full task infrastructure
from fastmcp.client.tasks import ResourceTask
diff --git a/tests/server/tasks/test_server_tasks_parameter.py b/tests/server/tasks/test_server_tasks_parameter.py
index acb30811e..45b777d6a 100644
--- a/tests/server/tasks/test_server_tasks_parameter.py
+++ b/tests/server/tasks/test_server_tasks_parameter.py
@@ -35,7 +35,7 @@ async def test_server_tasks_true_defaults_all_components():
async def my_resource() -> str:
return "resource result"
- async with Client(mcp) as client:
+ async with Client(mcp, mode="legacy") as client:
# Verify all task-enabled components are registered with docket
# Components use prefixed keys: tool:name, prompt:name, resource:uri
docket = mcp.docket
@@ -82,7 +82,7 @@ async def test_server_tasks_false_defaults_all_components():
async def my_resource() -> str:
return "resource result"
- async with Client(mcp) as client:
+ async with Client(mcp, mode="legacy") as client:
# Tool with mode="forbidden" returns error when called with task=True
tool_task = await client.call_tool("my_tool", task=True, raise_on_error=False)
assert tool_task.returned_immediately
@@ -107,7 +107,7 @@ async def test_server_tasks_none_defaults_to_false():
async def my_tool() -> str:
return "tool result"
- async with Client(mcp) as client:
+ async with Client(mcp, mode="legacy") as client:
# Tool should NOT support background execution (mode="forbidden" from default)
tool_task = await client.call_tool("my_tool", task=True, raise_on_error=False)
assert tool_task.returned_immediately
@@ -128,7 +128,7 @@ async def test_component_explicit_false_overrides_server_true():
async def default_tool() -> str:
return "background result"
- async with Client(mcp) as client:
+ async with Client(mcp, mode="legacy") as client:
# Verify docket registration matches task settings (prefixed keys)
docket = mcp.docket
assert docket is not None
@@ -163,7 +163,7 @@ async def test_component_explicit_true_overrides_server_false():
async def default_tool() -> str:
return "immediate result"
- async with Client(mcp) as client:
+ async with Client(mcp, mode="legacy") as client:
# Verify docket registration matches task settings (prefixed keys)
docket = mcp.docket
assert docket is not None
@@ -224,7 +224,7 @@ async def test_mixed_explicit_and_inherited():
async def explicit_false_resource() -> str:
return "explicit False"
- async with Client(mcp) as client:
+ async with Client(mcp, mode="legacy") as client:
# Verify docket registration matches task settings
# Components use prefixed keys: tool:name, prompt:name, resource:uri
docket = mcp.docket
@@ -282,7 +282,7 @@ async def test_server_tasks_parameter_sets_component_defaults():
async def tool_inherits_true() -> str:
return "tool result"
- async with Client(mcp) as client:
+ async with Client(mcp, mode="legacy") as client:
# Tool inherits tasks=True from server
tool_task = await client.call_tool("tool_inherits_true", task=True)
assert not tool_task.returned_immediately
@@ -294,7 +294,7 @@ async def test_server_tasks_parameter_sets_component_defaults():
async def tool_inherits_false() -> str:
return "tool result"
- async with Client(mcp2) as client:
+ async with Client(mcp2, mode="legacy") as client:
# Tool inherits tasks=False (mode="forbidden") - returns error
tool_task = await client.call_tool(
"tool_inherits_false", task=True, raise_on_error=False
@@ -318,7 +318,7 @@ async def test_resource_template_inherits_server_tasks_default():
async def templated_resource(item_id: str) -> str:
return f"resource {item_id}"
- async with Client(mcp) as client:
+ async with Client(mcp, mode="legacy") as client:
# Template should support background execution
resource_task = await client.read_resource("test://123", task=True)
assert not resource_task.returned_immediately
@@ -345,7 +345,7 @@ async def test_multiple_components_same_name_different_tasks():
async def shared_name_prompt() -> str:
return "prompt result"
- async with Client(mcp) as client:
+ async with Client(mcp, mode="legacy") as client:
# Tool with explicit True should support background execution
tool_task = await client.call_tool("shared_name", task=True)
assert not tool_task.returned_immediately
@@ -368,7 +368,7 @@ async def test_task_with_custom_tool_name():
mcp.tool(my_function, name="custom-tool-name")
- async with Client(mcp) as client:
+ async with Client(mcp, mode="legacy") as client:
# Verify the tool is registered with its custom name in Docket (prefixed key)
docket = mcp.docket
assert docket is not None
@@ -398,7 +398,7 @@ async def test_task_with_custom_resource_name():
async def my_resource_func() -> str:
return "result from custom-named resource"
- async with Client(mcp) as client:
+ async with Client(mcp, mode="legacy") as client:
# Verify the resource is registered with its key (prefixed URI) in Docket
docket = mcp.docket
assert docket is not None
@@ -428,7 +428,7 @@ async def test_task_with_custom_template_name():
async def my_template_func(item_id: str) -> str:
return f"result for {item_id}"
- async with Client(mcp) as client:
+ async with Client(mcp, mode="legacy") as client:
# Verify the template is registered with its key (prefixed uri_template) in Docket
docket = mcp.docket
assert docket is not None
diff --git a/tests/server/tasks/test_snapshot_restore.py b/tests/server/tasks/test_snapshot_restore.py
index 946e1bf1b..09a5241d1 100644
--- a/tests/server/tasks/test_snapshot_restore.py
+++ b/tests/server/tasks/test_snapshot_restore.py
@@ -39,7 +39,7 @@ async def test_snapshot_restored_before_user_code_runs():
seen_cached.append(_recall_snapshot(info.task_id) is not None)
return "ok"
- async with Client(mcp) as client:
+ async with Client(mcp, mode="legacy") as client:
task = await client.call_tool("bare_tool", {}, task=True)
await task.result()
@@ -66,7 +66,7 @@ async def test_get_access_token_in_bg_task_without_context_dep():
)
auth_context_var.set(AuthenticatedUser(test_token))
- async with Client(mcp) as client:
+ async with Client(mcp, mode="legacy") as client:
task = await client.call_tool("bare_tool", {}, task=True)
await task.result()
@@ -89,7 +89,7 @@ async def test_restore_failure_is_nonfatal():
def boom(*_args, **_kwargs):
raise RuntimeError("simulated deserialization failure")
- async with Client(mcp) as client:
+ async with Client(mcp, mode="legacy") as client:
with patch.object(TaskContextSnapshot, "from_json", boom):
task = await client.call_tool("bare_tool", {}, task=True)
result = await task.result()
diff --git a/tests/server/tasks/test_task_capabilities.py b/tests/server/tasks/test_task_capabilities.py
index 3999c5dbe..e504cee53 100644
--- a/tests/server/tasks/test_task_capabilities.py
+++ b/tests/server/tasks/test_task_capabilities.py
@@ -18,7 +18,7 @@ async def test_capabilities_include_tasks():
async def test_tool() -> str:
return "test"
- async with Client(mcp) as client:
+ async with Client(mcp, mode="legacy") as client:
# Get server initialization result which includes capabilities
init_result = client.initialize_result
@@ -59,7 +59,7 @@ async def test_client_uses_task_capable_session():
async def test_tool() -> str:
return "test"
- async with Client(mcp) as client:
+ async with Client(mcp, mode="legacy") as client:
# Client should have connected successfully with task capabilities
assert client.initialize_result is not None
# Session should be a ClientSession (task-capable init uses standard session)
diff --git a/tests/server/tasks/test_task_config.py b/tests/server/tasks/test_task_config.py
index d6e095509..e10d7cff2 100644
--- a/tests/server/tasks/test_task_config.py
+++ b/tests/server/tasks/test_task_config.py
@@ -110,7 +110,7 @@ class TestToolModeEnforcement:
async def test_required_mode_without_task_returns_error(self, server):
"""Required mode raises error when called without task metadata."""
- async with Client(server) as client:
+ async with Client(server, mode="legacy") as client:
with pytest.raises(ToolError) as exc_info:
await client.call_tool("required_tool", {})
@@ -118,7 +118,7 @@ class TestToolModeEnforcement:
async def test_required_mode_with_task_succeeds(self, server):
"""Required mode succeeds when called with task metadata."""
- async with Client(server) as client:
+ async with Client(server, mode="legacy") as client:
task = await client.call_tool("required_tool", {}, task=True)
assert task is not None
result = await task.result()
@@ -126,7 +126,7 @@ class TestToolModeEnforcement:
async def test_forbidden_mode_with_task_returns_error(self, server):
"""Forbidden mode returns error when called with task metadata."""
- async with Client(server) as client:
+ async with Client(server, mode="legacy") as client:
# Call with task=True should fail
task = await client.call_tool(
"forbidden_tool", {}, task=True, raise_on_error=False
@@ -140,19 +140,19 @@ class TestToolModeEnforcement:
async def test_forbidden_mode_without_task_succeeds(self, server):
"""Forbidden mode succeeds when called without task metadata."""
- async with Client(server) as client:
+ async with Client(server, mode="legacy") as client:
result = await client.call_tool("forbidden_tool", {})
assert "forbidden result" in str(result)
async def test_optional_mode_without_task_succeeds(self, server):
"""Optional mode succeeds when called without task metadata."""
- async with Client(server) as client:
+ async with Client(server, mode="legacy") as client:
result = await client.call_tool("optional_tool", {})
assert "optional result" in str(result)
async def test_optional_mode_with_task_succeeds(self, server):
"""Optional mode succeeds when called with task metadata."""
- async with Client(server) as client:
+ async with Client(server, mode="legacy") as client:
task = await client.call_tool("optional_tool", {}, task=True)
assert task is not None
result = await task.result()
@@ -188,7 +188,7 @@ class TestResourceModeEnforcement:
"""Required mode returns error when read without task metadata."""
from mcp_types import METHOD_NOT_FOUND
- async with Client(server) as client:
+ async with Client(server, mode="legacy") as client:
with pytest.raises(MCPError) as exc_info:
await client.read_resource("resource://required")
@@ -203,7 +203,7 @@ class TestResourceModeEnforcement:
)
async def test_required_resource_with_task_succeeds(self, server):
"""Required mode succeeds when read with task metadata."""
- async with Client(server) as client:
+ async with Client(server, mode="legacy") as client:
task = await client.read_resource("resource://required", task=True)
assert task is not None
result = await task.result()
@@ -212,7 +212,7 @@ class TestResourceModeEnforcement:
async def test_forbidden_resource_without_task_succeeds(self, server):
"""Forbidden mode succeeds when read without task metadata."""
- async with Client(server) as client:
+ async with Client(server, mode="legacy") as client:
result = await client.read_resource("resource://forbidden")
assert "forbidden content" in str(result)
@@ -246,7 +246,7 @@ class TestPromptModeEnforcement:
"""Required mode returns error when called without task metadata."""
from mcp_types import METHOD_NOT_FOUND
- async with Client(server) as client:
+ async with Client(server, mode="legacy") as client:
with pytest.raises(MCPError) as exc_info:
await client.get_prompt("required_prompt")
@@ -261,7 +261,7 @@ class TestPromptModeEnforcement:
)
async def test_required_prompt_with_task_succeeds(self, server):
"""Required mode succeeds when called with task metadata."""
- async with Client(server) as client:
+ async with Client(server, mode="legacy") as client:
task = await client.get_prompt("required_prompt", task=True)
assert task is not None
result = await task.result()
@@ -270,7 +270,7 @@ class TestPromptModeEnforcement:
async def test_forbidden_prompt_without_task_succeeds(self, server):
"""Forbidden mode succeeds when called without task metadata."""
- async with Client(server) as client:
+ async with Client(server, mode="legacy") as client:
result = await client.get_prompt("forbidden_prompt")
assert isinstance(result.messages[0].content, TextContent)
assert "forbidden message" in str(result.messages[0].content)
@@ -287,7 +287,7 @@ class TestToolExecutionMetadata:
async def my_tool() -> str:
return "ok"
- async with Client(mcp) as client:
+ async with Client(mcp, mode="legacy") as client:
tools = await client.list_tools()
tool = next(t for t in tools if t.name == "my_tool")
assert isinstance(tool, MCPTool)
@@ -302,7 +302,7 @@ class TestToolExecutionMetadata:
async def my_tool() -> str:
return "ok"
- async with Client(mcp) as client:
+ async with Client(mcp, mode="legacy") as client:
tools = await client.list_tools()
tool = next(t for t in tools if t.name == "my_tool")
assert isinstance(tool, MCPTool)
@@ -317,7 +317,7 @@ class TestToolExecutionMetadata:
async def my_tool() -> str:
return "ok"
- async with Client(mcp) as client:
+ async with Client(mcp, mode="legacy") as client:
tools = await client.list_tools()
tool = next(t for t in tools if t.name == "my_tool")
assert tool.execution is None
diff --git a/tests/server/tasks/test_task_dependencies.py b/tests/server/tasks/test_task_dependencies.py
index 0aef545ee..1745f5e49 100644
--- a/tests/server/tasks/test_task_dependencies.py
+++ b/tests/server/tasks/test_task_dependencies.py
@@ -75,7 +75,7 @@ async def dependency_server():
async def test_background_tool_receives_docket_dependency(dependency_server):
"""Background tools can use CurrentDocket() and it resolves correctly."""
- async with Client(dependency_server) as client:
+ async with Client(dependency_server, mode="legacy") as client:
task = await client.call_tool("tool_with_docket_dependency", {}, task=True)
# Verify it's background
@@ -96,7 +96,7 @@ async def test_background_tool_receives_server_dependency(dependency_server):
"""Background tools can use CurrentFastMCP() and get the actual FastMCP server."""
dependency_server._injected_values.clear()
- async with Client(dependency_server) as client:
+ async with Client(dependency_server, mode="legacy") as client:
task = await client.call_tool("tool_with_server_dependency", {}, task=True)
# Verify background execution
@@ -116,7 +116,7 @@ async def test_background_tool_receives_custom_depends(dependency_server):
"""Background tools can use Depends() with custom functions."""
dependency_server._injected_values.clear()
- async with Client(dependency_server) as client:
+ async with Client(dependency_server, mode="legacy") as client:
task = await client.call_tool(
"tool_with_custom_dependency", {"value": 5}, task=True
)
@@ -137,7 +137,7 @@ async def test_background_tool_with_multiple_dependencies(dependency_server):
"""Background tools can have multiple dependencies injected simultaneously."""
dependency_server._injected_values.clear()
- async with Client(dependency_server) as client:
+ async with Client(dependency_server, mode="legacy") as client:
task = await client.call_tool(
"tool_with_multiple_dependencies", {"name": "test"}, task=True
)
@@ -170,7 +170,7 @@ async def test_background_prompt_receives_dependencies(dependency_server):
"""Background prompts can use dependency injection."""
dependency_server._injected_values.clear()
- async with Client(dependency_server) as client:
+ async with Client(dependency_server, mode="legacy") as client:
task = await client.get_prompt(
"prompt_with_server_dependency", {"topic": "AI"}, task=True
)
@@ -196,7 +196,7 @@ async def test_background_resource_receives_dependencies(dependency_server):
"""Background resources can use dependency injection."""
dependency_server._injected_values.clear()
- async with Client(dependency_server) as client:
+ async with Client(dependency_server, mode="legacy") as client:
task = await client.read_resource("file://data.txt", task=True)
assert not task.returned_immediately
@@ -219,7 +219,7 @@ async def test_foreground_tool_dependencies_unaffected(dependency_server):
dependency_server._injected_values.append(("sync_server", server))
return f"Sync: {server.name}"
- async with Client(dependency_server) as client:
+ async with Client(dependency_server, mode="legacy") as client:
await client.call_tool("sync_tool", {})
# Should execute immediately
@@ -248,7 +248,7 @@ async def test_dependency_context_managers_cleaned_up_in_background():
assert "exit" not in cleanup_called # Still open during execution
return f"Used: {conn}"
- async with Client(mcp) as client:
+ async with Client(mcp, mode="legacy") as client:
task = await client.call_tool("use_connection", {"name": "test"}, task=True)
result = await task
@@ -270,7 +270,7 @@ async def test_dependency_errors_propagate_to_task_failure():
) -> str:
return f"Got: {dep}"
- async with Client(mcp) as client:
+ async with Client(mcp, mode="legacy") as client:
task = await client.call_tool(
"tool_with_failing_dep", {"value": "test"}, task=True
)
diff --git a/tests/server/tasks/test_task_elicitation_relay.py b/tests/server/tasks/test_task_elicitation_relay.py
index 42362edd2..bf8d6e8b9 100644
--- a/tests/server/tasks/test_task_elicitation_relay.py
+++ b/tests/server/tasks/test_task_elicitation_relay.py
@@ -7,7 +7,7 @@ an elicitation/create request to the client session. The client's
elicitation_handler fires, and the relay pushes the response to Redis
for the blocked worker.
-These tests use Client(mcp) with the real memory:// Docket backend.
+These tests use Client(mcp, mode="legacy") with the real memory:// Docket backend.
"""
import asyncio
@@ -44,7 +44,7 @@ class TestElicitationRelay:
assert message == "What is your name?"
return ElicitResult(action="accept", content={"value": "Alice"})
- async with Client(mcp, elicitation_handler=handler) as client:
+ async with Client(mcp, mode="legacy", elicitation_handler=handler) as client:
task = await client.call_tool("ask_name", {}, task=True)
result = await task.result()
assert result.data == "Hello, Alice!"
@@ -65,7 +65,7 @@ class TestElicitationRelay:
async def handler(message, response_type, params, ctx):
return ElicitResult(action="decline")
- async with Client(mcp, elicitation_handler=handler) as client:
+ async with Client(mcp, mode="legacy", elicitation_handler=handler) as client:
task = await client.call_tool("optional_input", {}, task=True)
result = await task.result()
assert result.data == "User declined"
@@ -84,7 +84,7 @@ class TestElicitationRelay:
async def handler(message, response_type, params, ctx):
return ElicitResult(action="cancel")
- async with Client(mcp, elicitation_handler=handler) as client:
+ async with Client(mcp, mode="legacy", elicitation_handler=handler) as client:
task = await client.call_tool("cancellable", {}, task=True)
result = await task.result()
assert result.data == "Cancelled"
@@ -109,7 +109,7 @@ class TestElicitationRelay:
async def handler(message, response_type, params, ctx):
return ElicitResult(action="accept", content={"name": "Bob", "age": 30})
- async with Client(mcp, elicitation_handler=handler) as client:
+ async with Client(mcp, mode="legacy", elicitation_handler=handler) as client:
task = await client.call_tool("get_user", {}, task=True)
result = await task.result()
assert result.data == "Bob is 30"
@@ -135,7 +135,7 @@ class TestElicitationRelay:
action="accept", content={"host": "localhost", "port": 8080}
)
- async with Client(mcp, elicitation_handler=handler) as client:
+ async with Client(mcp, mode="legacy", elicitation_handler=handler) as client:
task = await client.call_tool("get_config", {}, task=True)
result = await task.result()
assert result.data == "localhost:8080"
@@ -166,7 +166,7 @@ class TestElicitationRelay:
assert message == "Last name?"
return ElicitResult(action="accept", content={"value": "Doe"})
- async with Client(mcp, elicitation_handler=handler) as client:
+ async with Client(mcp, mode="legacy", elicitation_handler=handler) as client:
task = await client.call_tool("two_questions", {}, task=True)
result = await task.result()
assert result.data == "Jane Doe"
@@ -185,7 +185,7 @@ class TestElicitationRelay:
return f"Got: {result.data}"
return "Other"
- async with Client(mcp) as client:
+ async with Client(mcp, mode="legacy") as client:
task = await client.call_tool("needs_input", {}, task=True)
result = await asyncio.wait_for(task.result(), timeout=15.0)
assert result.data == "Cancelled as expected"
diff --git a/tests/server/tasks/test_task_meta_parameter.py b/tests/server/tasks/test_task_meta_parameter.py
index e76930637..bea973aba 100644
--- a/tests/server/tasks/test_task_meta_parameter.py
+++ b/tests/server/tasks/test_task_meta_parameter.py
@@ -76,7 +76,7 @@ class TestTaskMetaParameter:
# call_tool enriches the task_meta before passing to _run
# We test this via the client integration path
- async with Client(server) as client:
+ async with Client(server, mode="legacy") as client:
result = await client.call_tool("auto_key_tool", {}, task=True)
# Should succeed because fn_key was auto-populated
from fastmcp.client.tasks import ToolTask
@@ -105,7 +105,7 @@ class TestTaskMetaTTL:
custom_ttl_ms = 30000 # 30 seconds
- async with Client(server) as client:
+ async with Client(server, mode="legacy") as client:
# Use client.call_tool with task=True and ttl
task = await client.call_tool("ttl_tool", {}, task=True, ttl=custom_ttl_ms)
@@ -125,7 +125,7 @@ class TestTaskMetaTTL:
async def default_ttl_tool() -> str:
return "done"
- async with Client(server) as client:
+ async with Client(server, mode="legacy") as client:
# Use client.call_tool with task=True, default ttl
task = await client.call_tool("default_ttl_tool", {}, task=True)
@@ -169,7 +169,7 @@ class TestTaskMetaMiddleware:
server.add_middleware(TrackingMiddleware(middleware_saw_request))
- async with Client(server) as client:
+ async with Client(server, mode="legacy") as client:
# Use client to trigger the middleware chain
task = await client.call_tool("middleware_test_tool", {}, task=True)
@@ -193,7 +193,7 @@ class TestTaskMetaClientIntegration:
async def client_test_tool(x: int) -> int:
return x * 2
- async with Client(server) as client:
+ async with Client(server, mode="legacy") as client:
# Client passes task=True, server receives as task_meta
task = await client.call_tool("client_test_tool", {"x": 5}, task=True)
@@ -214,7 +214,7 @@ class TestTaskMetaClientIntegration:
async def immediate_tool(x: int) -> int:
return x * 2
- async with Client(server) as client:
+ async with Client(server, mode="legacy") as client:
# No task=True, should execute synchronously
result = await client.call_tool("immediate_tool", {"x": 5})
@@ -231,7 +231,7 @@ class TestTaskMetaClientIntegration:
custom_ttl_ms = 60000 # 60 seconds
- async with Client(server) as client:
+ async with Client(server, mode="legacy") as client:
task = await client.call_tool(
"custom_ttl_tool", {}, task=True, ttl=custom_ttl_ms
)
@@ -265,7 +265,7 @@ class TestTaskMetaDirectServerCall:
# Should get CreateTaskResult since we're in server context
return f"Created task: {result.task.task_id}"
- async with Client(server) as client:
+ async with Client(server, mode="legacy") as client:
# Call outer_tool which internally calls inner_tool with task_meta
result = await client.call_tool("outer_tool", {"x": 5})
# The outer tool should have successfully created a background task
@@ -288,7 +288,7 @@ class TestTaskMetaDirectServerCall:
assert isinstance(first_content, mcp_types.TextContent)
return f"Got result: {first_content.text}"
- async with Client(server) as client:
+ async with Client(server, mode="legacy") as client:
result = await client.call_tool("outer_tool", {"x": 5})
assert "Got result: 10" in str(result)
@@ -308,7 +308,7 @@ class TestTaskMetaDirectServerCall:
)
return f"Task TTL: {result.task.ttl}"
- async with Client(server) as client:
+ async with Client(server, mode="legacy") as client:
result = await client.call_tool("outer_tool", {"x": 5})
# The inner tool task should have the custom TTL
assert "Task TTL: 45000" in str(result)
diff --git a/tests/server/tasks/test_task_metadata.py b/tests/server/tasks/test_task_metadata.py
index 0d8935d36..2bfdf4b13 100644
--- a/tests/server/tasks/test_task_metadata.py
+++ b/tests/server/tasks/test_task_metadata.py
@@ -25,7 +25,7 @@ async def metadata_server():
async def test_tasks_get_includes_related_task_metadata(metadata_server: FastMCP):
"""tasks/get response includes io.modelcontextprotocol/related-task in _meta."""
- async with Client(metadata_server) as client:
+ async with Client(metadata_server, mode="legacy") as client:
# Submit a task
task = await client.call_tool("test_tool", {"value": 5}, task=True)
task_id = task.task_id
@@ -41,7 +41,7 @@ async def test_tasks_get_includes_related_task_metadata(metadata_server: FastMCP
async def test_tasks_result_includes_related_task_metadata(metadata_server: FastMCP):
"""tasks/result response includes io.modelcontextprotocol/related-task in _meta."""
- async with Client(metadata_server) as client:
+ async with Client(metadata_server, mode="legacy") as client:
# Submit and complete a task
task = await client.call_tool("test_tool", {"value": 7}, task=True)
result = await task.result()
@@ -54,7 +54,7 @@ async def test_tasks_result_includes_related_task_metadata(metadata_server: Fast
async def test_tasks_list_includes_related_task_metadata(metadata_server: FastMCP):
"""tasks/list response includes io.modelcontextprotocol/related-task in _meta."""
- async with Client(metadata_server) as client:
+ async with Client(metadata_server, mode="legacy") as client:
# List tasks via client (which uses protocol properly)
result = await client.list_tasks()
diff --git a/tests/server/tasks/test_task_methods.py b/tests/server/tasks/test_task_methods.py
index 493bc9167..4e8832ef6 100644
--- a/tests/server/tasks/test_task_methods.py
+++ b/tests/server/tasks/test_task_methods.py
@@ -39,7 +39,7 @@ async def endpoint_server():
async def test_tasks_get_endpoint_returns_status(endpoint_server):
"""POST /tasks/get returns task status."""
- async with Client(endpoint_server) as client:
+ async with Client(endpoint_server, mode="legacy") as client:
# Submit a task
task = await client.call_tool("quick_tool", {"value": 21}, task=True)
@@ -58,7 +58,7 @@ async def test_tasks_get_endpoint_returns_status(endpoint_server):
async def test_tasks_get_endpoint_includes_poll_interval(endpoint_server):
"""Task status includes pollFrequency hint."""
- async with Client(endpoint_server) as client:
+ async with Client(endpoint_server, mode="legacy") as client:
task = await client.call_tool("quick_tool", {"value": 42}, task=True)
status = await task.status()
@@ -68,7 +68,7 @@ async def test_tasks_get_endpoint_includes_poll_interval(endpoint_server):
async def test_tasks_result_endpoint_returns_result_when_completed(endpoint_server):
"""POST /tasks/result returns the tool result when completed."""
- async with Client(endpoint_server) as client:
+ async with Client(endpoint_server, mode="legacy") as client:
task = await client.call_tool("quick_tool", {"value": 21}, task=True)
# Wait for completion and get result
@@ -86,7 +86,7 @@ async def test_tasks_result_endpoint_errors_if_not_completed(endpoint_server):
await completion_signal.wait()
return "done"
- async with Client(endpoint_server) as client:
+ async with Client(endpoint_server, mode="legacy") as client:
task = await client.call_tool("blocked_tool", task=True)
# Try to get result immediately (task still running)
@@ -99,7 +99,7 @@ async def test_tasks_result_endpoint_errors_if_not_completed(endpoint_server):
async def test_tasks_result_endpoint_errors_if_task_not_found(endpoint_server):
"""POST /tasks/result returns error for non-existent task."""
- async with Client(endpoint_server) as client:
+ async with Client(endpoint_server, mode="legacy") as client:
# Try to get result for non-existent task
with pytest.raises(Exception):
await client.get_task_result("non-existent-task-id")
@@ -107,7 +107,7 @@ async def test_tasks_result_endpoint_errors_if_task_not_found(endpoint_server):
async def test_tasks_result_endpoint_returns_error_for_failed_task(endpoint_server):
"""POST /tasks/result returns error information for failed tasks."""
- async with Client(endpoint_server) as client:
+ async with Client(endpoint_server, mode="legacy") as client:
task = await client.call_tool("error_tool", task=True)
# Wait for task to fail
@@ -126,7 +126,7 @@ async def test_tasks_result_endpoint_returns_error_for_failed_task(endpoint_serv
async def test_tasks_list_endpoint_session_isolation(endpoint_server):
"""list_tasks returns only tasks submitted by this client."""
# Since client tracks tasks locally, this tests client-side tracking
- async with Client(endpoint_server) as client:
+ async with Client(endpoint_server, mode="legacy") as client:
# Submit multiple tasks (server generates IDs)
tasks = []
for i in range(3):
@@ -147,7 +147,7 @@ async def test_tasks_list_endpoint_session_isolation(endpoint_server):
async def test_get_status_nonexistent_task_raises_error(endpoint_server):
"""Getting status for nonexistent task raises MCP error (per SEP-1686 SDK behavior)."""
- async with Client(endpoint_server) as client:
+ async with Client(endpoint_server, mode="legacy") as client:
# Try to get status for task that was never created
# Per SDK implementation: raises ValueError which becomes JSON-RPC error
with pytest.raises(MCPError, match="Task nonexistent-task-id not found"):
@@ -156,7 +156,7 @@ async def test_get_status_nonexistent_task_raises_error(endpoint_server):
async def test_task_cancellation_workflow(endpoint_server):
"""Task can be cancelled, transitioning to cancelled state."""
- async with Client(endpoint_server) as client:
+ async with Client(endpoint_server, mode="legacy") as client:
# Submit slow task
task = await client.call_tool("slow_tool", {}, task=True)
@@ -199,7 +199,7 @@ async def test_task_cancellation_interrupts_running_coroutine(endpoint_server):
was_interrupted.set()
raise
- async with Client(endpoint_server) as client:
+ async with Client(endpoint_server, mode="legacy") as client:
task = await client.call_tool("interruptible_tool", {}, task=True)
# Wait for the tool to actually start executing
diff --git a/tests/server/tasks/test_task_mount.py b/tests/server/tasks/test_task_mount.py
index 0401259c1..218ef1a47 100644
--- a/tests/server/tasks/test_task_mount.py
+++ b/tests/server/tasks/test_task_mount.py
@@ -109,7 +109,7 @@ class TestMountedToolTasks:
async def test_mounted_tool_task_returns_task_object(self, parent_server):
"""Mounted tool called with task=True returns a task object."""
- async with Client(parent_server) as client:
+ async with Client(parent_server, mode="legacy") as client:
# Tool name is prefixed: child_multiply
task = await client.call_tool("child_multiply", {"a": 6, "b": 7}, task=True)
@@ -120,7 +120,7 @@ class TestMountedToolTasks:
async def test_mounted_tool_task_executes_in_background(self, parent_server):
"""Mounted tool task executes in background."""
- async with Client(parent_server) as client:
+ async with Client(parent_server, mode="legacy") as client:
task = await client.call_tool("child_multiply", {"a": 3, "b": 4}, task=True)
# Should execute in background
@@ -130,7 +130,7 @@ class TestMountedToolTasks:
self, parent_server: FastMCP
):
"""Mounted tool task returns correct result."""
- async with Client(parent_server) as client:
+ async with Client(parent_server, mode="legacy") as client:
task = await client.call_tool("child_multiply", {"a": 8, "b": 9}, task=True)
result = await task.result()
@@ -138,7 +138,7 @@ class TestMountedToolTasks:
async def test_mounted_tool_task_status(self, parent_server):
"""Can poll task status for mounted tool."""
- async with Client(parent_server) as client:
+ async with Client(parent_server, mode="legacy") as client:
task = await client.call_tool(
"child_slow_child_tool", {"duration": 0.5}, task=True
)
@@ -157,7 +157,7 @@ class TestMountedToolTasks:
@pytest.mark.timeout(10)
async def test_mounted_tool_task_cancellation(self, parent_server):
"""Can cancel a mounted tool task."""
- async with Client(parent_server) as client:
+ async with Client(parent_server, mode="legacy") as client:
task = await client.call_tool(
"child_slow_child_tool", {"duration": 10.0}, task=True
)
@@ -174,7 +174,7 @@ class TestMountedToolTasks:
async def test_graceful_degradation_sync_mounted_tool(self, parent_server):
"""Sync-only mounted tool returns error with task=True."""
- async with Client(parent_server) as client:
+ async with Client(parent_server, mode="legacy") as client:
task = await client.call_tool(
"child_sync_child_tool",
{"message": "hello"},
@@ -190,7 +190,7 @@ class TestMountedToolTasks:
async def test_parent_and_mounted_tools_both_work(self, parent_server):
"""Both parent and mounted tools work as tasks."""
- async with Client(parent_server) as client:
+ async with Client(parent_server, mode="legacy") as client:
# Parent tool
parent_task = await client.call_tool("parent_tool", {"value": 5}, task=True)
# Mounted tool
@@ -212,7 +212,7 @@ class TestMountedToolTasksNoPrefix:
self, parent_server_no_prefix
):
"""Mounted tool without prefix works as task."""
- async with Client(parent_server_no_prefix) as client:
+ async with Client(parent_server_no_prefix, mode="legacy") as client:
# No prefix, so tool keeps original name
task = await client.call_tool("multiply", {"a": 5, "b": 6}, task=True)
@@ -227,7 +227,7 @@ class TestMountedPromptTasks:
async def test_mounted_prompt_task_returns_task_object(self, parent_server):
"""Mounted prompt called with task=True returns a task object."""
- async with Client(parent_server) as client:
+ async with Client(parent_server, mode="legacy") as client:
# Prompt name is prefixed: child_child_prompt
task = await client.get_prompt(
"child_child_prompt", {"topic": "FastMCP"}, task=True
@@ -245,7 +245,7 @@ class TestMountedPromptTasks:
)
async def test_mounted_prompt_task_executes_in_background(self, parent_server):
"""Mounted prompt task executes in background."""
- async with Client(parent_server) as client:
+ async with Client(parent_server, mode="legacy") as client:
task = await client.get_prompt(
"child_child_prompt", {"topic": "testing"}, task=True
)
@@ -256,7 +256,7 @@ class TestMountedPromptTasks:
self, parent_server: FastMCP
):
"""Mounted prompt task returns correct result."""
- async with Client(parent_server) as client:
+ async with Client(parent_server, mode="legacy") as client:
task = await client.get_prompt(
"child_child_prompt", {"topic": "MCP protocol"}, task=True
)
@@ -271,7 +271,7 @@ class TestMountedResourceTasks:
async def test_mounted_resource_task_returns_task_object(self, parent_server):
"""Mounted resource read with task=True returns a task object."""
- async with Client(parent_server) as client:
+ async with Client(parent_server, mode="legacy") as client:
# Resource URI is prefixed: child://child/data.txt
task = await client.read_resource("child://child/data.txt", task=True)
@@ -287,14 +287,14 @@ class TestMountedResourceTasks:
)
async def test_mounted_resource_task_executes_in_background(self, parent_server):
"""Mounted resource task executes in background."""
- async with Client(parent_server) as client:
+ async with Client(parent_server, mode="legacy") as client:
task = await client.read_resource("child://child/data.txt", task=True)
assert not task.returned_immediately
async def test_mounted_resource_task_returns_correct_result(self, parent_server):
"""Mounted resource task returns correct result."""
- async with Client(parent_server) as client:
+ async with Client(parent_server, mode="legacy") as client:
task = await client.read_resource("child://child/data.txt", task=True)
result = await task.result()
@@ -309,7 +309,7 @@ class TestMountedResourceTasks:
)
async def test_mounted_resource_template_task(self, parent_server):
"""Mounted resource template with task=True works."""
- async with Client(parent_server) as client:
+ async with Client(parent_server, mode="legacy") as client:
task = await client.read_resource("child://child/item/99.json", task=True)
assert not task.returned_immediately
@@ -335,7 +335,7 @@ class TestMountedTaskDependencies:
parent = FastMCP("dep-parent")
parent.mount(child, namespace="child")
- async with Client(parent) as client:
+ async with Client(parent, mode="legacy") as client:
task = await client.call_tool("child_tool_with_docket", {}, task=True)
result = await task.result()
@@ -356,7 +356,7 @@ class TestMountedTaskDependencies:
parent = FastMCP("server-dep-parent")
parent.mount(child, namespace="child")
- async with Client(parent) as client:
+ async with Client(parent, mode="legacy") as client:
task = await client.call_tool("child_tool_with_server", {}, task=True)
await task.result()
@@ -380,7 +380,7 @@ class TestMountedTaskServerContext:
parent = FastMCP("parent")
parent.mount(child, namespace="child")
- async with Client(parent) as client:
+ async with Client(parent, mode="legacy") as client:
task = await client.call_tool("child_whoami", {}, task=True)
result = await task.result()
@@ -403,7 +403,7 @@ class TestMountedTaskServerContext:
parent = FastMCP("parent")
parent.mount(child, namespace="child")
- async with Client(parent) as client:
+ async with Client(parent, mode="legacy") as client:
task = await client.call_tool("child_whoami_ctx", {}, task=True)
result = await task.result()
@@ -427,7 +427,7 @@ class TestMountedTaskServerContext:
parent = FastMCP("parent")
parent.mount(child, namespace="child")
- async with Client(parent) as client:
+ async with Client(parent, mode="legacy") as client:
task = await client.call_tool("child_gc_deep_whoami", {}, task=True)
result = await task.result()
@@ -456,7 +456,7 @@ class TestMultipleMounts:
parent.mount(child1, namespace="math1")
parent.mount(child2, namespace="math2")
- async with Client(parent) as client:
+ async with Client(parent, mode="legacy") as client:
task1 = await client.call_tool("math1_add", {"a": 10, "b": 5}, task=True)
task2 = await client.call_tool(
"math2_subtract", {"a": 10, "b": 5}, task=True
@@ -489,7 +489,7 @@ class TestMountedFunctionNameCollisions:
parent.mount(child1, namespace="c1")
parent.mount(child2, namespace="c2")
- async with Client(parent) as client:
+ async with Client(parent, mode="legacy") as client:
# Both should execute their own implementation
task1 = await client.call_tool("c1_process", {"value": 10}, task=True)
task2 = await client.call_tool("c2_process", {"value": 10}, task=True)
@@ -517,7 +517,7 @@ class TestMountedFunctionNameCollisions:
parent.mount(child1) # No prefix
parent.mount(child2) # No prefix - overwrites child1's "process"
- async with Client(parent) as client:
+ async with Client(parent, mode="legacy") as client:
# Last mount wins - child2's process should execute
task = await client.call_tool("process", {"value": 10}, task=True)
result = await task.result()
@@ -536,7 +536,7 @@ class TestMountedFunctionNameCollisions:
child.mount(grandchild, namespace="gc")
parent.mount(child, namespace="child")
- async with Client(parent) as client:
+ async with Client(parent, mode="legacy") as client:
# Tool should be accessible and execute correctly
task = await client.call_tool("child_gc_deep_tool", {}, task=True)
result = await task.result()
@@ -548,7 +548,7 @@ class TestMountedTaskList:
async def test_list_tasks_includes_mounted_tasks(self, parent_server):
"""Task list includes tasks from mounted server tools."""
- async with Client(parent_server) as client:
+ async with Client(parent_server, mode="legacy") as client:
# Create tasks on both parent and mounted tools
parent_task = await client.call_tool("parent_tool", {"value": 1}, task=True)
child_task = await client.call_tool(
@@ -645,13 +645,13 @@ class TestMountedTaskConfigModes:
async def test_optional_mode_sync_through_mount(self, parent_with_modes):
"""Optional mode tool works without task through mount."""
- async with Client(parent_with_modes) as client:
+ async with Client(parent_with_modes, mode="legacy") as client:
result = await client.call_tool("child_optional_tool", {})
assert "optional result" in str(result)
async def test_optional_mode_task_through_mount(self, parent_with_modes):
"""Optional mode tool works with task through mount."""
- async with Client(parent_with_modes) as client:
+ async with Client(parent_with_modes, mode="legacy") as client:
task = await client.call_tool("child_optional_tool", {}, task=True)
assert task is not None
result = await task.result()
@@ -659,7 +659,7 @@ class TestMountedTaskConfigModes:
async def test_required_mode_with_task_through_mount(self, parent_with_modes):
"""Required mode tool succeeds with task through mount."""
- async with Client(parent_with_modes) as client:
+ async with Client(parent_with_modes, mode="legacy") as client:
task = await client.call_tool("child_required_tool", {}, task=True)
assert task is not None
result = await task.result()
@@ -669,7 +669,7 @@ class TestMountedTaskConfigModes:
"""Required mode tool errors without task through mount."""
from fastmcp.exceptions import ToolError
- async with Client(parent_with_modes) as client:
+ async with Client(parent_with_modes, mode="legacy") as client:
with pytest.raises(ToolError) as exc_info:
await client.call_tool("child_required_tool", {})
@@ -677,13 +677,13 @@ class TestMountedTaskConfigModes:
async def test_forbidden_mode_sync_through_mount(self, parent_with_modes):
"""Forbidden mode tool works without task through mount."""
- async with Client(parent_with_modes) as client:
+ async with Client(parent_with_modes, mode="legacy") as client:
result = await client.call_tool("child_forbidden_tool", {})
assert "forbidden result" in str(result)
async def test_forbidden_mode_with_task_through_mount(self, parent_with_modes):
"""Forbidden mode tool degrades gracefully with task through mount."""
- async with Client(parent_with_modes) as client:
+ async with Client(parent_with_modes, mode="legacy") as client:
task = await client.call_tool(
"child_forbidden_tool", {}, task=True, raise_on_error=False
)
@@ -787,7 +787,7 @@ class TestMiddlewareWithMountedTasks:
parent.mount(child, namespace="c")
parent.add_middleware(ToolTracingMiddleware("parent", calls))
- async with Client(parent) as client:
+ async with Client(parent, mode="legacy") as client:
task = await client.call_tool("c_gc_compute", {"x": 5}, task=True)
result = await task.result()
assert result.data == 10
@@ -831,7 +831,7 @@ class TestMiddlewareWithMountedTasks:
parent.mount(child, namespace="c")
parent.add_middleware(ResourceTracingMiddleware("parent", calls))
- async with Client(parent) as client:
+ async with Client(parent, mode="legacy") as client:
task = await client.read_resource("data://c/gc/value", task=True)
result = await task.result()
assert result[0].text == "result"
@@ -874,7 +874,7 @@ class TestMiddlewareWithMountedTasks:
parent.mount(child, namespace="c")
parent.add_middleware(PromptTracingMiddleware("parent", calls))
- async with Client(parent) as client:
+ async with Client(parent, mode="legacy") as client:
task = await client.get_prompt("c_gc_greet", {"name": "World"}, task=True)
result = await task.result()
assert result.messages[0].content.text == "Hello, World!"
@@ -917,7 +917,7 @@ class TestMiddlewareWithMountedTasks:
parent.mount(child, namespace="c")
parent.add_middleware(ResourceTracingMiddleware("parent", calls))
- async with Client(parent) as client:
+ async with Client(parent, mode="legacy") as client:
task = await client.read_resource("item://c/gc/42", task=True)
result = await task.result()
assert result[0].text == "item-42"
@@ -966,7 +966,7 @@ class TestMountedTasksWithTaskMetaParameter:
)
return f"task:{result.task.task_id}"
- async with Client(parent) as client:
+ async with Client(parent, mode="legacy") as client:
result = await client.call_tool("outer", {})
assert "task:" in str(result)
@@ -990,7 +990,7 @@ class TestMountedTasksWithTaskMetaParameter:
)
return f"task:{result.task.task_id}"
- async with Client(parent) as client:
+ async with Client(parent, mode="legacy") as client:
result = await client.call_tool("outer", {})
assert "task:" in str(result)
@@ -1014,7 +1014,7 @@ class TestMountedTasksWithTaskMetaParameter:
)
return f"task:{result.task.task_id}"
- async with Client(parent) as client:
+ async with Client(parent, mode="legacy") as client:
result = await client.call_tool("outer", {})
assert "task:" in str(result)
@@ -1041,7 +1041,7 @@ class TestMountedTasksWithTaskMetaParameter:
)
return f"task:{result.task.task_id}"
- async with Client(parent) as client:
+ async with Client(parent, mode="legacy") as client:
result = await client.call_tool("outer", {})
assert "task:" in str(result)
@@ -1068,7 +1068,7 @@ class TestMountedTasksWithTaskMetaParameter:
)
return f"task:{result.task.task_id}"
- async with Client(parent) as client:
+ async with Client(parent, mode="legacy") as client:
result = await client.call_tool("outer", {})
assert "task:" in str(result)
@@ -1092,7 +1092,7 @@ class TestMountedTasksWithTaskMetaParameter:
)
return f"task:{result.task.task_id}"
- async with Client(parent) as client:
+ async with Client(parent, mode="legacy") as client:
result = await client.call_tool("outer", {})
assert "task:" in str(result)
@@ -1119,6 +1119,6 @@ class TestMountedTasksWithTaskMetaParameter:
)
return f"task:{result.task.task_id}"
- async with Client(parent) as client:
+ async with Client(parent, mode="legacy") as client:
result = await client.call_tool("outer", {})
assert "task:" in str(result)
diff --git a/tests/server/tasks/test_task_prompts.py b/tests/server/tasks/test_task_prompts.py
index ca62a3a0f..1054d3cee 100644
--- a/tests/server/tasks/test_task_prompts.py
+++ b/tests/server/tasks/test_task_prompts.py
@@ -31,7 +31,7 @@ async def prompt_server():
async def test_synchronous_prompt_unchanged(prompt_server):
"""Prompts without task metadata execute synchronously as before."""
- async with Client(prompt_server) as client:
+ async with Client(prompt_server, mode="legacy") as client:
# Regular call without task metadata
result = await client.get_prompt("simple_prompt", {"topic": "AI"})
@@ -41,7 +41,7 @@ async def test_synchronous_prompt_unchanged(prompt_server):
async def test_prompt_with_task_metadata_returns_immediately(prompt_server):
"""Prompts with task metadata return immediately with PromptTask object."""
- async with Client(prompt_server) as client:
+ async with Client(prompt_server, mode="legacy") as client:
# Call with task metadata
task = await client.get_prompt("background_prompt", {"topic": "AI"}, task=True)
@@ -59,7 +59,7 @@ async def test_prompt_with_task_metadata_returns_immediately(prompt_server):
)
async def test_prompt_task_executes_in_background(prompt_server):
"""Prompt task executes via Docket in background."""
- async with Client(prompt_server) as client:
+ async with Client(prompt_server, mode="legacy") as client:
task = await client.get_prompt(
"background_prompt",
{"topic": "Machine Learning", "depth": "comprehensive"},
@@ -89,7 +89,7 @@ async def test_forbidden_mode_prompt_rejects_task_calls(prompt_server):
async def sync_only_prompt(topic: str) -> str:
return f"Sync prompt: {topic}"
- async with Client(prompt_server) as client:
+ async with Client(prompt_server, mode="legacy") as client:
# Calling with task=True when task=False should raise MCPError
import pytest
diff --git a/tests/server/tasks/test_task_protocol.py b/tests/server/tasks/test_task_protocol.py
index 9fd2fd7a6..f47648618 100644
--- a/tests/server/tasks/test_task_protocol.py
+++ b/tests/server/tasks/test_task_protocol.py
@@ -31,7 +31,7 @@ async def task_enabled_server():
async def test_task_metadata_includes_task_id_and_ttl(task_enabled_server):
"""Task metadata properly includes server-generated taskId and ttl."""
- async with Client(task_enabled_server) as client:
+ async with Client(task_enabled_server, mode="legacy") as client:
# Submit with specific ttl (server generates task ID)
task = await client.call_tool(
"simple_tool",
@@ -54,7 +54,7 @@ async def test_task_notification_sent_after_submission(task_enabled_server):
async def background_tool(message: str) -> str:
return f"Processed: {message}"
- async with Client(task_enabled_server) as client:
+ async with Client(task_enabled_server, mode="legacy") as client:
task = await client.call_tool("background_tool", {"message": "test"}, task=True)
assert task
assert not task.returned_immediately
@@ -71,7 +71,7 @@ async def test_failed_task_stores_error(task_enabled_server):
async def failing_task_tool() -> str:
raise ValueError("This tool always fails")
- async with Client(task_enabled_server) as client:
+ async with Client(task_enabled_server, mode="legacy") as client:
task = await client.call_tool("failing_task_tool", task=True)
assert task
assert not task.returned_immediately
diff --git a/tests/server/tasks/test_task_proxy.py b/tests/server/tasks/test_task_proxy.py
index c272a4444..c8abd5c5d 100644
--- a/tests/server/tasks/test_task_proxy.py
+++ b/tests/server/tasks/test_task_proxy.py
@@ -68,13 +68,13 @@ class TestProxyToolsSyncExecution:
async def test_tool_sync_execution_works(self, proxy_server: FastMCP):
"""Tool called without task=True works through proxy."""
- async with Client(proxy_server) as client:
+ async with Client(proxy_server, mode="legacy") as client:
result = await client.call_tool("add_numbers", {"a": 5, "b": 3})
assert "8" in str(result)
async def test_sync_only_tool_works(self, proxy_server: FastMCP):
"""Sync-only tool works through proxy."""
- async with Client(proxy_server) as client:
+ async with Client(proxy_server, mode="legacy") as client:
result = await client.call_tool("sync_only_tool", {"message": "test"})
assert "sync: test" in str(result)
@@ -84,7 +84,7 @@ class TestProxyToolsTaskForbidden:
async def test_tool_task_returns_error_immediately(self, proxy_server: FastMCP):
"""Tool called with task=True through proxy returns error immediately."""
- async with Client(proxy_server) as client:
+ async with Client(proxy_server, mode="legacy") as client:
task = await client.call_tool(
"add_numbers", {"a": 5, "b": 3}, task=True, raise_on_error=False
)
@@ -100,7 +100,7 @@ class TestProxyToolsTaskForbidden:
self, proxy_server: FastMCP
):
"""Sync-only tool with task=True also returns error immediately."""
- async with Client(proxy_server) as client:
+ async with Client(proxy_server, mode="legacy") as client:
task = await client.call_tool(
"sync_only_tool",
{"message": "test"},
@@ -118,7 +118,7 @@ class TestProxyPromptsSyncExecution:
async def test_prompt_sync_execution_works(self, proxy_server: FastMCP):
"""Prompt called without task=True works through proxy."""
- async with Client(proxy_server) as client:
+ async with Client(proxy_server, mode="legacy") as client:
result = await client.get_prompt("greeting_prompt", {"name": "Alice"})
assert isinstance(result.messages[0].content, TextContent)
assert "Hello, Alice!" in result.messages[0].content.text
@@ -135,7 +135,7 @@ class TestProxyPromptsTaskForbidden:
)
async def test_prompt_task_raises_mcp_error(self, proxy_server: FastMCP):
"""Prompt called with task=True through proxy raises MCPError."""
- async with Client(proxy_server) as client:
+ async with Client(proxy_server, mode="legacy") as client:
with pytest.raises(MCPError) as exc_info:
await client.get_prompt("greeting_prompt", {"name": "Alice"}, task=True)
@@ -147,14 +147,14 @@ class TestProxyResourcesSyncExecution:
async def test_resource_sync_execution_works(self, proxy_server: FastMCP):
"""Resource read without task=True works through proxy."""
- async with Client(proxy_server) as client:
+ async with Client(proxy_server, mode="legacy") as client:
result = await client.read_resource("data://info.txt")
assert isinstance(result[0], TextResourceContents)
assert "Important information from the backend" in result[0].text
async def test_resource_template_sync_execution_works(self, proxy_server: FastMCP):
"""Resource template without task=True works through proxy."""
- async with Client(proxy_server) as client:
+ async with Client(proxy_server, mode="legacy") as client:
result = await client.read_resource("data://user/42.json")
assert isinstance(result[0], TextResourceContents)
assert '"id": "42"' in result[0].text
@@ -171,7 +171,7 @@ class TestProxyResourcesTaskForbidden:
)
async def test_resource_task_raises_mcp_error(self, proxy_server: FastMCP):
"""Resource read with task=True through proxy raises MCPError."""
- async with Client(proxy_server) as client:
+ async with Client(proxy_server, mode="legacy") as client:
with pytest.raises(MCPError) as exc_info:
await client.read_resource("data://info.txt", task=True)
@@ -185,7 +185,7 @@ class TestProxyResourcesTaskForbidden:
)
async def test_resource_template_task_raises_mcp_error(self, proxy_server: FastMCP):
"""Resource template with task=True through proxy raises MCPError."""
- async with Client(proxy_server) as client:
+ async with Client(proxy_server, mode="legacy") as client:
with pytest.raises(MCPError) as exc_info:
await client.read_resource("data://user/42.json", task=True)
diff --git a/tests/server/tasks/test_task_resources.py b/tests/server/tasks/test_task_resources.py
index f7768adc7..acb136281 100644
--- a/tests/server/tasks/test_task_resources.py
+++ b/tests/server/tasks/test_task_resources.py
@@ -36,7 +36,7 @@ async def resource_server():
async def test_synchronous_resource_unchanged(resource_server):
"""Resources without task metadata execute synchronously as before."""
- async with Client(resource_server) as client:
+ async with Client(resource_server, mode="legacy") as client:
# Regular call without task metadata
result = await client.read_resource("file://data.txt")
@@ -46,7 +46,7 @@ async def test_synchronous_resource_unchanged(resource_server):
async def test_resource_with_task_metadata_returns_immediately(resource_server):
"""Resources with task metadata return immediately with ResourceTask object."""
- async with Client(resource_server) as client:
+ async with Client(resource_server, mode="legacy") as client:
# Call with task metadata
task = await client.read_resource("file://large.txt", task=True)
@@ -64,7 +64,7 @@ async def test_resource_with_task_metadata_returns_immediately(resource_server):
)
async def test_resource_task_executes_in_background(resource_server):
"""Resource task executes via Docket in background."""
- async with Client(resource_server) as client:
+ async with Client(resource_server, mode="legacy") as client:
task = await client.read_resource("file://large.txt", task=True)
# Verify background execution
@@ -84,7 +84,7 @@ async def test_resource_task_executes_in_background(resource_server):
)
async def test_resource_template_with_task(resource_server):
"""Resource templates with task=True execute in background."""
- async with Client(resource_server) as client:
+ async with Client(resource_server, mode="legacy") as client:
task = await client.read_resource("file://user/123/data.json", task=True)
# Verify background execution
@@ -113,7 +113,7 @@ async def test_forbidden_mode_resource_rejects_task_calls(resource_server):
async def sync_only_resource() -> str:
return "Sync content"
- async with Client(resource_server) as client:
+ async with Client(resource_server, mode="legacy") as client:
# Calling with task=True when task=False should raise MCPError
with pytest.raises(MCPError) as exc_info:
await client.read_resource("file://sync.txt", task=True)
diff --git a/tests/server/tasks/test_task_return_types.py b/tests/server/tasks/test_task_return_types.py
index 15452c79f..a8255a5a7 100644
--- a/tests/server/tasks/test_task_return_types.py
+++ b/tests/server/tasks/test_task_return_types.py
@@ -96,7 +96,7 @@ async def test_task_basic_types(
expected_value: Any,
):
"""Task mode returns basic types correctly."""
- async with Client(return_type_server) as client:
+ async with Client(return_type_server, mode="legacy") as client:
task = await client.call_tool(tool_name, task=True)
result = await task
assert isinstance(result.data, expected_type)
@@ -105,7 +105,7 @@ async def test_task_basic_types(
async def test_task_model_return(return_type_server):
"""Task mode returns same BaseModel (as dict) as immediate mode."""
- async with Client(return_type_server) as client:
+ async with Client(return_type_server, mode="legacy") as client:
task = await client.call_tool("return_model", task=True)
result = await task
@@ -118,7 +118,7 @@ async def test_task_model_return(return_type_server):
async def test_task_vs_immediate_equivalence(return_type_server):
"""Verify task mode and immediate mode return identical results."""
- async with Client(return_type_server) as client:
+ async with Client(return_type_server, mode="legacy") as client:
# Test a few types to verify equivalence
tools_to_test = ["return_string", "return_int", "return_dict"]
@@ -160,7 +160,7 @@ async def prompt_return_server():
async def test_prompt_task_single_message(prompt_return_server):
"""Prompt task returns single message correctly."""
- async with Client(prompt_return_server) as client:
+ async with Client(prompt_return_server, mode="legacy") as client:
task = await client.get_prompt("single_message_prompt", task=True)
result = await task
@@ -170,7 +170,7 @@ async def test_prompt_task_single_message(prompt_return_server):
async def test_prompt_task_multiple_messages(prompt_return_server):
"""Prompt task returns multiple messages correctly."""
- async with Client(prompt_return_server) as client:
+ async with Client(prompt_return_server, mode="legacy") as client:
task = await client.get_prompt("multi_message_prompt", task=True)
result = await task
@@ -202,7 +202,7 @@ async def resource_return_server():
async def test_resource_task_text_content(resource_return_server):
"""Resource task returns text content correctly."""
- async with Client(resource_return_server) as client:
+ async with Client(resource_return_server, mode="legacy") as client:
task = await client.read_resource("text://simple", task=True)
contents = await task
@@ -212,7 +212,7 @@ async def test_resource_task_text_content(resource_return_server):
async def test_resource_task_json_content(resource_return_server):
"""Resource task returns structured content correctly."""
- async with Client(resource_return_server) as client:
+ async with Client(resource_return_server, mode="legacy") as client:
task = await client.read_resource("data://json", task=True)
contents = await task
@@ -287,7 +287,7 @@ async def test_task_binary_types(
assertion_fn: Any,
):
"""Task mode handles binary and special types."""
- async with Client(binary_type_server) as client:
+ async with Client(binary_type_server, mode="legacy") as client:
task = await client.call_tool(tool_name, task=True)
result = await task
assert isinstance(result.data, expected_type)
@@ -338,7 +338,7 @@ async def test_task_collection_types(
expected_value: Any,
):
"""Task mode handles collection types."""
- async with Client(collection_server) as client:
+ async with Client(collection_server, mode="legacy") as client:
task = await client.call_tool(tool_name, task=True)
result = await task
assert isinstance(result.data, expected_type)
@@ -347,7 +347,7 @@ async def test_task_collection_types(
async def test_task_empty_dict_return(collection_server):
"""Task mode handles empty dict return."""
- async with Client(collection_server) as client:
+ async with Client(collection_server, mode="legacy") as client:
task = await client.call_tool("return_empty_dict", task=True)
result = await task
# Empty structured content becomes None in data
@@ -426,7 +426,7 @@ async def test_task_media_types(
assertion_fn: Any,
):
"""Task mode handles media types (Image, Audio, File)."""
- async with Client(media_server) as client:
+ async with Client(media_server, mode="legacy") as client:
task = await client.call_tool(tool_name, task=True)
result = await task
assert assertion_fn(result)
@@ -498,7 +498,7 @@ async def test_task_structured_dict_types(
expected_age: int,
):
"""Task mode handles TypedDict and dataclass returns."""
- async with Client(structured_type_server) as client:
+ async with Client(structured_type_server, mode="legacy") as client:
task = await client.call_tool(tool_name, task=True)
result = await task
# Both deserialize to dynamic Root class
@@ -520,7 +520,7 @@ async def test_task_union_types(
expected_value: Any,
):
"""Task mode handles union type branches."""
- async with Client(structured_type_server) as client:
+ async with Client(structured_type_server, mode="legacy") as client:
task = await client.call_tool(tool_name, task=True)
result = await task
assert isinstance(result.data, expected_type)
@@ -541,7 +541,7 @@ async def test_task_optional_types(
expected_value: Any,
):
"""Task mode handles Optional types."""
- async with Client(structured_type_server) as client:
+ async with Client(structured_type_server, mode="legacy") as client:
task = await client.call_tool(tool_name, task=True)
result = await task
assert isinstance(result.data, expected_type)
@@ -650,7 +650,7 @@ async def test_task_mcp_content_types(
assertion_fn: Any,
):
"""Task mode handles MCP content block types."""
- async with Client(mcp_content_server) as client:
+ async with Client(mcp_content_server, mode="legacy") as client:
task = await client.call_tool(tool_name, task=True)
result = await task
assert assertion_fn(result)
@@ -658,7 +658,7 @@ async def test_task_mcp_content_types(
async def test_task_mixed_content_return(mcp_content_server):
"""Task mode handles mixed content list return."""
- async with Client(mcp_content_server) as client:
+ async with Client(mcp_content_server, mode="legacy") as client:
task = await client.call_tool("return_mixed_content", task=True)
result = await task
assert len(result.content) == 3
diff --git a/tests/server/tasks/test_task_security.py b/tests/server/tasks/test_task_security.py
index 5d3b16ffa..605382894 100644
--- a/tests/server/tasks/test_task_security.py
+++ b/tests/server/tasks/test_task_security.py
@@ -38,7 +38,7 @@ async def test_same_client_can_access_all_its_tasks(task_server: FastMCP):
)
reset = auth_context_var.set(AuthenticatedUser(token))
try:
- async with Client(task_server) as client:
+ async with Client(task_server, mode="legacy") as client:
task1 = await client.call_tool(
"secret_tool", {"data": "first"}, task=True, task_id="task-1"
)
@@ -60,7 +60,7 @@ async def test_same_client_can_access_all_its_tasks(task_server: FastMCP):
async def test_unauthenticated_client_can_access_its_tasks(task_server: FastMCP):
"""An unauthenticated client can access tasks it created (by task ID)."""
- async with Client(task_server) as client:
+ async with Client(task_server, mode="legacy") as client:
task = await client.call_tool(
"secret_tool", {"data": "hello"}, task=True, task_id="my-task"
)
@@ -95,14 +95,14 @@ async def test_distinct_clients_cannot_access_each_others_tasks(
a peer's task id returns 'not found'."""
reset = _set_auth("client-a")
try:
- async with Client(task_server) as client_a:
+ async with Client(task_server, mode="legacy") as client_a:
task_id = await _submit_task_id(client_a, "client-a-secret")
finally:
auth_context_var.reset(reset)
reset = _set_auth("client-b")
try:
- async with Client(task_server) as client_b:
+ async with Client(task_server, mode="legacy") as client_b:
with pytest.raises(Exception, match="not found"):
await client_b.get_task_status(task_id)
finally:
@@ -118,14 +118,14 @@ async def test_distinct_subs_same_client_id_cannot_access_each_others_tasks(
reset = _set_auth(shared_client, sub="user-alice")
try:
- async with Client(task_server) as alice:
+ async with Client(task_server, mode="legacy") as alice:
task_id = await _submit_task_id(alice, "alice-secret")
finally:
auth_context_var.reset(reset)
reset = _set_auth(shared_client, sub="user-bob")
try:
- async with Client(task_server) as bob:
+ async with Client(task_server, mode="legacy") as bob:
with pytest.raises(Exception, match="not found"):
await bob.get_task_status(task_id)
finally:
@@ -139,11 +139,11 @@ async def test_authenticated_and_anonymous_keyspaces_are_disjoint(
tasks (and vice versa) even when colliding on task id."""
reset = _set_auth("client-a")
try:
- async with Client(task_server) as authed:
+ async with Client(task_server, mode="legacy") as authed:
authed_task_id = await _submit_task_id(authed, "authed-secret")
finally:
auth_context_var.reset(reset)
- async with Client(task_server) as anon:
+ async with Client(task_server, mode="legacy") as anon:
with pytest.raises(Exception, match="not found"):
await anon.get_task_status(authed_task_id)
diff --git a/tests/server/tasks/test_task_status_notifications.py b/tests/server/tasks/test_task_status_notifications.py
index 98d333ca9..1b292bc73 100644
--- a/tests/server/tasks/test_task_status_notifications.py
+++ b/tests/server/tasks/test_task_status_notifications.py
@@ -55,7 +55,7 @@ async def notification_server():
async def test_subscription_spawned_for_tool_task(notification_server: FastMCP):
"""Subscription task is spawned when tool task is created."""
- async with Client(notification_server) as client:
+ async with Client(notification_server, mode="legacy") as client:
# Create task - should spawn subscription
task = await client.call_tool("quick_task", {"value": 5}, task=True)
@@ -69,7 +69,7 @@ async def test_subscription_spawned_for_tool_task(notification_server: FastMCP):
async def test_subscription_handles_task_completion(notification_server: FastMCP):
"""Subscription properly handles task completion and cleanup."""
- async with Client(notification_server) as client:
+ async with Client(notification_server, mode="legacy") as client:
# Multiple tasks should each get their own subscription
task1 = await client.call_tool("quick_task", {"value": 1}, task=True)
task2 = await client.call_tool("quick_task", {"value": 2}, task=True)
@@ -91,7 +91,7 @@ async def test_subscription_handles_task_completion(notification_server: FastMCP
async def test_subscription_handles_task_failure(notification_server: FastMCP):
"""Subscription properly handles task failure."""
- async with Client(notification_server) as client:
+ async with Client(notification_server, mode="legacy") as client:
task = await client.call_tool("failing_task", {}, task=True)
# Task should fail
@@ -104,7 +104,7 @@ async def test_subscription_handles_task_failure(notification_server: FastMCP):
async def test_subscription_for_prompt_tasks(notification_server: FastMCP):
"""Subscriptions work for prompt tasks."""
- async with Client(notification_server) as client:
+ async with Client(notification_server, mode="legacy") as client:
task = await client.get_prompt("test_prompt", {"name": "World"}, task=True)
result = await task
@@ -117,7 +117,7 @@ async def test_subscription_for_prompt_tasks(notification_server: FastMCP):
async def test_subscription_for_resource_tasks(notification_server: FastMCP):
"""Subscriptions work for resource tasks."""
- async with Client(notification_server) as client:
+ async with Client(notification_server, mode="legacy") as client:
task = await client.read_resource("test://resource", task=True)
result = await task
@@ -132,7 +132,7 @@ async def test_subscriptions_cleanup_on_session_disconnect(
):
"""Subscriptions are cleaned up when session disconnects."""
# Start session and create task
- async with Client(notification_server) as client:
+ async with Client(notification_server, mode="legacy") as client:
task = await client.call_tool("slow_task", {"duration": 1.0}, task=True)
task_id = task.task_id
# Disconnect before task completes (session __aexit__ cancels subscriptions)
@@ -145,7 +145,7 @@ async def test_subscriptions_cleanup_on_session_disconnect(
async def test_multiple_concurrent_subscriptions(notification_server: FastMCP):
"""Multiple concurrent tasks each have their own subscription."""
- async with Client(notification_server) as client:
+ async with Client(notification_server, mode="legacy") as client:
# Start many tasks concurrently
tasks = []
for i in range(10):
diff --git a/tests/server/tasks/test_task_tools.py b/tests/server/tasks/test_task_tools.py
index 1515504d1..c9d269bf7 100644
--- a/tests/server/tasks/test_task_tools.py
+++ b/tests/server/tasks/test_task_tools.py
@@ -59,7 +59,7 @@ async def test_task_tool_validates_model_arguments():
arguments = {"item": {"value": "a"}, "items": [{"value": "b"}]}
expected = {"item": "_Item", "element": "_Item"}
- async with Client(mcp) as client:
+ async with Client(mcp, mode="legacy") as client:
sync_result = await client.call_tool("inspect_items", arguments)
task = await client.call_tool("inspect_items", arguments, task=True)
task_result = await task.result()
@@ -92,7 +92,7 @@ async def test_task_tool_invalid_arguments_fail_before_task_state():
return item.value
recorder = _Recorder()
- async with Client(server, message_handler=recorder) as client:
+ async with Client(server, mode="legacy", message_handler=recorder) as client:
# `item` is missing its required `value` field.
task = await client.call_tool("needs_item", {"item": {}}, task=True)
assert task.returned_immediately
@@ -126,7 +126,7 @@ async def test_task_submission_honors_strict_input_validation():
return n * n
recorder = _Recorder()
- async with Client(server, message_handler=recorder) as client:
+ async with Client(server, mode="legacy", message_handler=recorder) as client:
# Sync path rejects the string-for-int coercion under strict validation.
with pytest.raises(ToolError):
await client.call_tool("square", {"n": "1"})
@@ -149,7 +149,7 @@ async def test_task_submission_valid_argument_under_strict_validation():
async def square(n: int) -> int:
return n * n
- async with Client(server) as client:
+ async with Client(server, mode="legacy") as client:
task = await client.call_tool("square", {"n": 4}, task=True)
assert not task.returned_immediately
result = await task.result()
@@ -174,7 +174,7 @@ def test_resolve_param_hints_handles_partials():
async def test_synchronous_tool_call_unchanged(tool_server):
"""Tools without task metadata execute synchronously as before."""
- async with Client(tool_server) as client:
+ async with Client(tool_server, mode="legacy") as client:
# Regular call without task metadata
result = await client.call_tool("simple_tool", {"message": "hello"})
@@ -184,7 +184,7 @@ async def test_synchronous_tool_call_unchanged(tool_server):
async def test_tool_with_task_metadata_returns_immediately(tool_server):
"""Tools with task metadata return immediately with ToolTask object."""
- async with Client(tool_server) as client:
+ async with Client(tool_server, mode="legacy") as client:
# Call with task metadata
task = await client.call_tool("simple_tool", {"message": "test"}, task=True)
assert task
@@ -207,7 +207,7 @@ async def test_tool_task_executes_in_background(tool_server):
await execution_completed.wait()
return "completed"
- async with Client(tool_server) as client:
+ async with Client(tool_server, mode="legacy") as client:
task = await client.call_tool("coordinated_tool", task=True)
assert task
assert not task.returned_immediately
@@ -229,7 +229,7 @@ async def test_tool_task_executes_in_background(tool_server):
async def test_forbidden_mode_tool_rejects_task_calls(tool_server):
"""Tools with task=False (mode=forbidden) reject task-augmented calls."""
- async with Client(tool_server) as client:
+ async with Client(tool_server, mode="legacy") as client:
# Calling with task=True when task=False should return error
task = await client.call_tool(
"sync_only_tool", {"message": "test"}, task=True, raise_on_error=False
diff --git a/tests/server/tasks/test_task_ttl.py b/tests/server/tasks/test_task_ttl.py
index 769fafffa..5760e4f99 100644
--- a/tests/server/tasks/test_task_ttl.py
+++ b/tests/server/tasks/test_task_ttl.py
@@ -32,7 +32,7 @@ async def keepalive_server():
async def test_keepalive_returned_in_submitted_state(keepalive_server: FastMCP):
"""ttl is returned in tasks/get even when task is submitted/working."""
- async with Client(keepalive_server) as client:
+ async with Client(keepalive_server, mode="legacy") as client:
# Submit task with explicit ttl
task = await client.call_tool(
"slow_task",
@@ -55,7 +55,7 @@ async def test_keepalive_returned_in_submitted_state(keepalive_server: FastMCP):
async def test_keepalive_returned_in_completed_state(keepalive_server: FastMCP):
"""ttl is returned in tasks/get after task completes."""
- async with Client(keepalive_server) as client:
+ async with Client(keepalive_server, mode="legacy") as client:
# Submit and complete task
task = await client.call_tool(
"quick_task",
@@ -77,7 +77,7 @@ async def test_keepalive_returned_in_completed_state(keepalive_server: FastMCP):
async def test_default_keepalive_when_not_specified(keepalive_server: FastMCP):
"""Default ttl is used when client doesn't specify."""
- async with Client(keepalive_server) as client:
+ async with Client(keepalive_server, mode="legacy") as client:
# Submit without explicit ttl
task = await client.call_tool("quick_task", {"value": 3}, task=True)
await task.wait(timeout=2.0)
diff --git a/tests/server/telemetry/test_sampling_tracing.py b/tests/server/telemetry/test_sampling_tracing.py
index db02b13bc..af9392344 100644
--- a/tests/server/telemetry/test_sampling_tracing.py
+++ b/tests/server/telemetry/test_sampling_tracing.py
@@ -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")
diff --git a/tests/server/telemetry/test_server_tracing.py b/tests/server/telemetry/test_server_tracing.py
index c04f49fd6..a9bc92334 100644
--- a/tests/server/telemetry/test_server_tracing.py
+++ b/tests/server/telemetry/test_server_tracing.py
@@ -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()
diff --git a/tests/server/test_icons.py b/tests/server/test_icons.py
index 9fba37b9b..3bbf0c527 100644
--- a/tests/server/test_icons.py
+++ b/tests/server/test_icons.py
@@ -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
diff --git a/tests/server/test_session_visibility.py b/tests/server/test_session_visibility.py
index d98a0d72f..6a6f9b105 100644
--- a/tests/server/test_session_visibility.py
+++ b/tests/server/test_session_visibility.py
@@ -70,7 +70,7 @@ class TestSessionVisibility:
assert rules[0]["tags"] == ["finance"]
return "activated"
- async with Client(mcp) as client:
+ async with Client(mcp, mode="legacy") as client:
result = await client.call_tool("activate_finance", {})
assert result.data == "activated"
@@ -94,7 +94,7 @@ class TestSessionVisibility:
assert rules[0]["tags"] == ["internal"]
return "deactivated"
- async with Client(mcp) as client:
+ async with Client(mcp, mode="legacy") as client:
result = await client.call_tool("deactivate_internal", {})
assert result.data == "deactivated"
@@ -116,7 +116,7 @@ class TestSessionVisibility:
# Globally disable finance tools
mcp.disable(tags={"finance"})
- async with Client(mcp) as client:
+ async with Client(mcp, mode="legacy") as client:
# Before activation, finance tool should not be visible
tools_before = await client.list_tools()
assert not any(t.name == "finance_tool" for t in tools_before)
@@ -151,7 +151,7 @@ class TestSessionVisibility:
# Globally disable finance tools
mcp.disable(tags={"finance"})
- async with Client(mcp) as client:
+ async with Client(mcp, mode="legacy") as client:
# Activate finance
await client.call_tool("activate_finance", {})
@@ -182,13 +182,13 @@ class TestSessionVisibility:
mcp.disable(tags={"finance"})
# Session A activates finance
- async with Client(mcp) as client_a:
+ async with Client(mcp, mode="legacy") as client_a:
await client_a.call_tool("activate_finance", {})
tools_a = await client_a.list_tools()
assert any(t.name == "finance_tool" for t in tools_a)
# Session B should not see finance tool (different session)
- async with Client(mcp) as client_b:
+ async with Client(mcp, mode="legacy") as client_b:
tools_b = await client_b.list_tools()
assert not any(t.name == "finance_tool" for t in tools_b)
@@ -220,7 +220,7 @@ class TestSessionVisibility:
# Globally disable all versioned tools
mcp.disable(names={"old_tool", "new_tool"})
- async with Client(mcp) as client:
+ async with Client(mcp, mode="legacy") as client:
# Enable v2 tools
await client.call_tool("enable_v2_only", {})
@@ -254,7 +254,7 @@ class TestSessionVisibility:
# Globally disable finance tools
mcp.disable(tags={"finance"})
- async with Client(mcp) as client:
+ async with Client(mcp, mode="legacy") as client:
# Activate finance
await client.call_tool("activate_finance", {})
tools_after_activate = await client.list_tools()
@@ -292,7 +292,7 @@ class TestSessionVisibility:
# Globally disable finance and admin tools
mcp.disable(tags={"finance", "admin"})
- async with Client(mcp) as client:
+ async with Client(mcp, mode="legacy") as client:
# Activate both
await client.call_tool("activate_multiple", {})
@@ -318,7 +318,7 @@ class TestSessionVisibility:
await ctx.disable_components(tags={"test"})
return "toggled"
- async with Client(mcp) as client:
+ async with Client(mcp, mode="legacy") as client:
# Toggle (enable then disable)
await client.call_tool("toggle_test", {})
@@ -344,7 +344,7 @@ class TestSessionVisibility:
# Globally disable finance resources
mcp.disable(tags={"finance"})
- async with Client(mcp) as client:
+ async with Client(mcp, mode="legacy") as client:
# Before activation, finance resource should not be visible
resources_before = await client.list_resources()
assert not any(str(r.uri) == "resource://finance" for r in resources_before)
@@ -374,7 +374,7 @@ class TestSessionVisibility:
# Globally disable finance prompts
mcp.disable(tags={"finance"})
- async with Client(mcp) as client:
+ async with Client(mcp, mode="legacy") as client:
# Before activation, finance prompt should not be visible
prompts_before = await client.list_prompts()
assert not any(p.name == "finance_prompt" for p in prompts_before)
@@ -402,7 +402,7 @@ class TestSessionVisibilityNotifications:
return "activated"
handler = RecordingMessageHandler()
- async with Client(mcp, message_handler=handler) as client:
+ async with Client(mcp, mode="legacy", message_handler=handler) as client:
handler.reset()
await client.call_tool("activate", {})
@@ -432,7 +432,7 @@ class TestSessionVisibilityNotifications:
return "deactivated"
handler = RecordingMessageHandler()
- async with Client(mcp, message_handler=handler) as client:
+ async with Client(mcp, mode="legacy", message_handler=handler) as client:
handler.reset()
await client.call_tool("deactivate", {})
@@ -461,7 +461,7 @@ class TestSessionVisibilityNotifications:
return "cleared"
handler = RecordingMessageHandler()
- async with Client(mcp, message_handler=handler) as client:
+ async with Client(mcp, mode="legacy", message_handler=handler) as client:
handler.reset()
await client.call_tool("clear", {})
@@ -491,7 +491,7 @@ class TestSessionVisibilityNotifications:
return "activated"
handler = RecordingMessageHandler()
- async with Client(mcp, message_handler=handler) as client:
+ async with Client(mcp, mode="legacy", message_handler=handler) as client:
handler.reset()
await client.call_tool("activate_tools_only", {})
@@ -537,7 +537,7 @@ class TestConcurrentSessionIsolation:
async def session_a():
nonlocal session_a_sees_finance
- async with Client(mcp) as client:
+ async with Client(mcp, mode="legacy") as client:
# Activate finance for this session
await client.call_tool("activate_finance", {})
@@ -556,7 +556,7 @@ class TestConcurrentSessionIsolation:
# Wait for session A to activate
await ready_event.wait()
- async with Client(mcp) as client:
+ async with Client(mcp, mode="legacy") as client:
# Session B should NOT see finance tool
tools = await client.list_tools()
session_b_sees_finance = any(t.name == "finance_tool" for t in tools)
@@ -590,13 +590,13 @@ class TestConcurrentSessionIsolation:
results: dict[str, bool] = {}
async def activated_session(session_id: str):
- async with Client(mcp) as client:
+ async with Client(mcp, mode="legacy") as client:
await client.call_tool("activate_premium", {})
tools = await client.list_tools()
results[session_id] = any(t.name == "premium_tool" for t in tools)
async def non_activated_session(session_id: str):
- async with Client(mcp) as client:
+ async with Client(mcp, mode="legacy") as client:
tools = await client.list_tools()
results[session_id] = any(t.name == "premium_tool" for t in tools)
@@ -644,7 +644,7 @@ class TestSessionVisibilityResetBug:
await ctx.reset_visibility()
return "exited"
- async with Client(mcp) as client:
+ async with Client(mcp, mode="legacy") as client:
# Tool visible initially
tools = await client.list_tools()
assert any(t.name == "my_tool" for t in tools)
@@ -681,7 +681,7 @@ class TestSessionVisibilityResetBug:
await ctx.reset_visibility()
return "exited"
- async with Client(mcp) as client:
+ async with Client(mcp, mode="legacy") as client:
for i in range(3):
# create_project should be visible
tools = await client.list_tools()
@@ -719,7 +719,7 @@ class TestSessionVisibilityResetBug:
check_done = anyio.Event()
async def session_a():
- async with Client(mcp) as client:
+ async with Client(mcp, mode="legacy") as client:
await client.call_tool("disable_system", {})
ready.set()
await check_done.wait()
@@ -727,7 +727,7 @@ class TestSessionVisibilityResetBug:
async def session_b():
nonlocal session_b_sees_tool
await ready.wait()
- async with Client(mcp) as client:
+ async with Client(mcp, mode="legacy") as client:
tools = await client.list_tools()
session_b_sees_tool = any(t.name == "shared_tool" for t in tools)
check_done.set()
@@ -756,13 +756,13 @@ class TestSessionVisibilityResetBug:
return "disabled"
# Session A disables the tool (no reset)
- async with Client(mcp) as client_a:
+ async with Client(mcp, mode="legacy") as client_a:
await client_a.call_tool("disable_system", {})
tools = await client_a.list_tools()
assert not any(t.name == "shared_tool" for t in tools)
# Session B should see it fresh
- async with Client(mcp) as client_b:
+ async with Client(mcp, mode="legacy") as client_b:
tools = await client_b.list_tools()
assert any(t.name == "shared_tool" for t in tools), (
"New session should see shared_tool regardless of previous session"
diff --git a/tests/server/test_tool_annotations.py b/tests/server/test_tool_annotations.py
index 096fd451d..46bb44e5e 100644
--- a/tests/server/test_tool_annotations.py
+++ b/tests/server/test_tool_annotations.py
@@ -229,7 +229,7 @@ async def test_task_execution_auto_populated_for_task_enabled_tool():
"""A tool that runs in background."""
return f"Processed: {data}"
- async with Client(mcp) as client:
+ async with Client(mcp, mode="legacy") as client:
tools_result = await client.list_tools()
assert len(tools_result) == 1
assert tools_result[0].name == "background_tool"
diff --git a/tests/server/transforms/test_search.py b/tests/server/transforms/test_search.py
index 981d044f2..241743ce2 100644
--- a/tests/server/transforms/test_search.py
+++ b/tests/server/transforms/test_search.py
@@ -188,7 +188,7 @@ class TestBaseTransformBehavior:
await ctx.disable_components(names={"delete_record"})
return "disabled"
- async with Client(mcp) as client:
+ async with Client(mcp, mode="legacy") as client:
# Before disabling, search should find delete_record
result = await client.call_tool("search_tools", {"pattern": "delete"})
found = _parse_tool_result(result)
diff --git a/tests/test_apps.py b/tests/test_apps.py
index dac7276cc..6aa65afed 100644
--- a/tests/test_apps.py
+++ b/tests/test_apps.py
@@ -417,14 +417,14 @@ class TestExtensionAdvertisement:
experimental_capabilities={"file_exchange": {"version": "0.3"}},
)
- async with Client(server) as client:
+ async with Client(server, mode="legacy") as client:
experimental = client.initialize_result.capabilities.experimental or {}
assert experimental.get("file_exchange") == {"version": "0.3"}
async def test_experimental_capabilities_default_empty(self):
server = FastMCP("test")
- async with Client(server) as client:
+ async with Client(server, mode="legacy") as client:
experimental = client.initialize_result.capabilities.experimental
assert not experimental
diff --git a/tests/test_compat.py b/tests/test_compat.py
index bb2c1233a..ec38cd6f6 100644
--- a/tests/test_compat.py
+++ b/tests/test_compat.py
@@ -231,7 +231,7 @@ class TestClientBehaviorCompat:
assert result.data == "hi"
async def test_ping_returns_bool(self, server):
- client = Client(transport=FastMCPTransport(server))
+ client = Client(transport=FastMCPTransport(server), mode="legacy")
async with client:
result = await client.ping()
assert result is True
diff --git a/tests/tools/tool_transform/test_tool_transform.py b/tests/tools/tool_transform/test_tool_transform.py
index f9e50df72..8e3f79350 100644
--- a/tests/tools/tool_transform/test_tool_transform.py
+++ b/tests/tools/tool_transform/test_tool_transform.py
@@ -736,7 +736,7 @@ class TestProxy:
)
proxy_server.add_tool(new_add_tool)
- async with Client(proxy_server) as client:
+ async with Client(proxy_server, mode="legacy") as client:
# The tool should be registered with its transformed name
result = await client.call_tool("add_transformed", {"new_x": 1, "old_y": 2})
assert isinstance(result.content[0], TextContent)