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)