mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-09 07:09:11 +02:00
Default fastmcp.Client to mode="auto"; surface extensions=/result_claims=
Client negotiates the newest mutual protocol era by default (probe server/discover, fall back to the initialize handshake). ProxyClient and the inspect utility explicitly pin the handshake era so proxy forwarding and server_info reads are unchanged. SSE and multi-server config transports are legacy-only. extensions= and result_claims= (SEP-2133) are thin passthroughs to the SDK session.
This commit is contained in:
parent
2676864163
commit
d2ac7ed3d2
84 changed files with 1553 additions and 601 deletions
|
|
@ -171,19 +171,19 @@ async with client:
|
|||
|
||||
MCP has two protocol eras: the original *legacy* era, which begins every connection with an `initialize` handshake, and the *modern* era (protocol version `2026-07-28` and later), which a client discovers by probing the server's `server/discover` endpoint. The `mode` parameter controls which era the client negotiates when it connects.
|
||||
|
||||
By default, `mode="legacy"`. This runs the initialize handshake and behaves identically to earlier FastMCP versions, so existing code connecting to any server keeps working unchanged.
|
||||
By default, `mode="auto"`. The client probes `server/discover` and adopts the modern protocol when the server responds; for any server that is not positive evidence of modern support, it falls back to the legacy handshake. This makes the default safe against a mixed fleet of legacy and modern servers.
|
||||
|
||||
```python
|
||||
from fastmcp import Client
|
||||
|
||||
# Legacy handshake (the default)
|
||||
client = Client("https://example.com/mcp", mode="legacy")
|
||||
# Negotiate the newest era the server supports (the default)
|
||||
client = Client("https://example.com/mcp", mode="auto")
|
||||
```
|
||||
|
||||
Set `mode="auto"` to negotiate the newest era the server supports. The client probes `server/discover` and adopts the modern protocol when the server responds; for any server that is not positive evidence of modern support, it falls back to the legacy handshake. This makes `"auto"` safe to use against a mixed fleet of legacy and modern servers.
|
||||
Set `mode="legacy"` to force the initialize handshake. This behaves identically to earlier FastMCP versions and is the opt-out if a server misbehaves under discovery or you need the legacy `initialize` result object.
|
||||
|
||||
```python
|
||||
client = Client("https://example.com/mcp", mode="auto")
|
||||
client = Client("https://example.com/mcp", mode="legacy")
|
||||
```
|
||||
|
||||
You can also pin a specific modern protocol version to adopt it directly, without a discovery probe:
|
||||
|
|
@ -201,7 +201,9 @@ async with Client("https://example.com/mcp", mode="auto") as client:
|
|||
```
|
||||
|
||||
<Note>
|
||||
`mode="auto"` is not the default yet — the conservative `"legacy"` remains the default to preserve byte-identical behavior against pre-2026 servers. Whether `"auto"` becomes the default is a future release decision.
|
||||
`mode="auto"` is the default as of FastMCP 4.0. Earlier versions defaulted to `"legacy"`. If a server behaves unexpectedly under discovery, or you depend on the legacy `initialize` result, pin the old behavior with `Client(..., mode="legacy")`.
|
||||
|
||||
The SSE transport is legacy-only — it cannot carry the sessionless modern era — so a client connecting over SSE always negotiates the legacy handshake, even under `mode="auto"`. A multi-server config (`MCPConfigTransport` with more than one server) is likewise legacy-only, because it mounts each backend behind a legacy-era proxy; a single-server config mirrors its one backend transport's era.
|
||||
</Note>
|
||||
|
||||
## Response caching
|
||||
|
|
@ -260,6 +262,31 @@ client = Client("https://example.com/mcp", mode="auto", cache=config)
|
|||
|
||||
The adapter serializes each result through a type-tagged envelope validated against an allowlist of cacheable result models, so a value naming an unknown type is treated as a cache miss rather than deserialized blindly. Each store instance owns its own collection namespace; `clear()` affects only that namespace, never another tenant's entries.
|
||||
|
||||
## Client extensions
|
||||
|
||||
<VersionBadge version="4.0.0" />
|
||||
|
||||
Client extensions (SEP-2133) are the advanced mechanism a client uses to opt into vendor capabilities that live outside the core protocol. An extension is a `ClientExtension` instance that bundles three things: a capability *advertisement* the server can read, one or more *result claims* that let the client parse extra `tools/call` result shapes, and *notification bindings* that observe server notifications the core protocol doesn't define. Pass a sequence of them to `extensions=`.
|
||||
|
||||
```python
|
||||
from fastmcp import Client
|
||||
from myproject.extensions import AppsExtension
|
||||
|
||||
client = Client("https://example.com/mcp", extensions=[AppsExtension()])
|
||||
```
|
||||
|
||||
Each extension's contributions are threaded into the underlying session. Notification bindings compose with FastMCP's own internal task-status binding rather than replacing it, so an extension that observes a custom notification and FastMCP's task tracking both work on the same connection. When a tool returns a shape an extension claims, `client.call_tool()` resolves it transparently through the owning claim's resolver and hands you back an ordinary result. Result claims and their advertisements are honored only on modern-era connections, so they are inert on a legacy handshake.
|
||||
|
||||
For the rare case where you need to register additional result claims against an extension that is already advertised, pass them through `result_claims=`, keyed by the extension's identifier. Prefer declaring claims on the extension itself; this parameter merges extra claims with an extension's own.
|
||||
|
||||
```python
|
||||
client = Client(
|
||||
"https://example.com/mcp",
|
||||
extensions=[AppsExtension()],
|
||||
result_claims={"example.com/apps": [extra_claim]},
|
||||
)
|
||||
```
|
||||
|
||||
## Operations
|
||||
|
||||
FastMCP clients interact with three types of server components.
|
||||
|
|
|
|||
|
|
@ -188,7 +188,28 @@ There is deliberately no compatibility alias for the old spelling.
|
|||
|
||||
## Client
|
||||
|
||||
The `fastmcp.Client` public API is preserved exactly. The client stays a wrapper around `mcp.ClientSession` in legacy/handshake mode; the first-class `mcp.client.Client` is deliberately not adopted in this PR.
|
||||
The `fastmcp.Client` public API is largely preserved. The client stays a wrapper around `mcp.ClientSession`; the first-class `mcp.client.Client` is deliberately not adopted. Two client-surface changes are called out below: the connection `mode` default flips to `"auto"`, and `extensions=` / `result_claims=` are newly surfaced.
|
||||
|
||||
### Connection `mode` defaults to `"auto"` — Breaking (behavior)
|
||||
|
||||
`Client(mode=...)` now defaults to `"auto"` instead of `"legacy"`. The client probes `server/discover` and adopts the modern (`2026-07-28`) era when the server responds, denylist-falling-back to the initialize handshake for any server that is not positive evidence of a modern peer. Against a FastMCP server (which serves both eras), an ordinary `Client(url)` now negotiates the modern era by default, where the legacy-only Context push features are unavailable per the per-feature era matrix (see the *Protocol eras* section below) — server-initiated sampling/elicitation/roots, `ping`, session ids, and FastMCP task submission all require the legacy era. The one-line revert is `Client(..., mode="legacy")`, which restores byte-identical pre-v4 negotiation.
|
||||
|
||||
The SSE transport is legacy-only (it cannot carry the sessionless modern era), so a client connecting over SSE negotiates the legacy handshake even under `mode="auto"` — expressed by a `ClientTransport.legacy_only` flag set on `SSETransport`. `MCPConfigTransport` reports `legacy_only` as a property: a multi-server config is legacy-only (each backend is mounted behind a legacy-era proxy), while a single-server config mirrors its one backend transport's era so a modern Streamable HTTP backend stays modern-capable. Two internal library clients that are inherently handshake-based are pinned to legacy so the flip does not break them: the `ProxyClient` backend (which forwards the initialize handshake and server-initiated features) defaults to `mode="legacy"`, and the `inspect` utility (which reads the full `server_info` only the handshake carries) connects legacy.
|
||||
|
||||
```python
|
||||
from fastmcp import Client
|
||||
|
||||
client = Client("https://example.com/mcp") # now negotiates "auto"
|
||||
client = Client("https://example.com/mcp", mode="legacy") # opt back into the handshake
|
||||
```
|
||||
|
||||
*Verify:* `fastmcp_slim/fastmcp/client/client.py` (`mode` default, `_negotiate` `legacy_only` shortcut), `fastmcp_slim/fastmcp/client/transports/{base,sse,config}.py` (`legacy_only`), `fastmcp_slim/fastmcp/server/providers/proxy.py` (`ProxyClient` legacy default), `fastmcp_slim/fastmcp/mcp_config.py` and `fastmcp_slim/fastmcp/utilities/inspect.py` (legacy inner clients), `tests/client/client/test_mode_negotiation.py` (default, clean discover-rejection fallback, legacy-only transport), `tests/test_mcp_config.py` (single- vs multi-server `legacy_only`), `docs/clients/client.mdx`.
|
||||
|
||||
### `extensions=` / `result_claims=` surfaced — New (opt-in feature)
|
||||
|
||||
`fastmcp.Client` now accepts `extensions=` (a sequence of SEP-2133 `ClientExtension` instances) and `result_claims=` (extra `ResultClaim`s keyed by an advertised extension's identifier). Each extension's capability advertisement, result claims, and notification bindings are folded into the underlying `ClientSession` on every transport. User-supplied notification bindings **compose** with FastMCP's internal task-status binding rather than clobbering it: the task binding always leads, and a user extension that binds the same method surfaces a clear duplicate-method error at connect time rather than silently winning. Result claims are wired end-to-end: `call_tool()` / `call_tool_mcp()` pass `allow_claimed=True` and resolve a claimed result through the owning claim's resolver (`ClaimContext`), so a server-emitted claimed shape is finished into an ordinary `CallToolResult` instead of raising `UnexpectedClaimedResult`. Claimed shapes are modern-only, so they are inert on a legacy connection.
|
||||
|
||||
*Verify:* `fastmcp_slim/fastmcp/client/client.py` (`_fold_extensions`, `_build_extension_kwargs`, `_resolve_claimed_result`, `new()`), `fastmcp_slim/fastmcp/client/mixins/tools.py` (`call_tool_mcp` claim resolution), `fastmcp_slim/fastmcp/client/transports/base.py` (`SessionKwargs.extensions`/`result_claims`), `tests/client/test_client_extensions.py` (fold, composition, live both-bindings-fire, end-to-end claim resolution).
|
||||
|
||||
### Transports yield 2-tuples — Absorbed
|
||||
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ The complete picture of what a FastMCP v4 server and client provide on the `2026
|
|||
| **Cache hints (SEP-2549)** | Server-level authoring (`FastMCP(cache_ttl=..., cache_scope=...)`) stamps every cacheable result; the FastMCP client honors hints with an opt-in response cache. |
|
||||
| **Distributed response caching** | `KeyValueResponseCacheStore` backs the client cache with any key-value store (Redis, memory, filetree), so a fleet of clients or proxy replicas shares cache fills across processes. |
|
||||
| **Resource path security** | Templated resource parameters are screened for traversal, absolute paths, and null bytes before handlers run — on by default, including provider-sourced and mounted templates. |
|
||||
| **Client protocol negotiation** | `Client(mode="auto")` probes `server/discover` and falls back to the classic handshake; the client answers multi-round-trip `input_required` requests through its existing handlers. |
|
||||
| **Client protocol negotiation** | `Client(mode="auto")` — the default as of v4 — probes `server/discover` and falls back to the classic handshake; the client answers multi-round-trip `input_required` requests through its existing handlers. Pin `mode="legacy"` to force the handshake. |
|
||||
| **Elicitation on the modern protocol (SEP-2322)** | Tools request user input via multi-round trips: a tool returns an `InputRequiredResult` and re-runs per round, reading the client's answers off `ctx.input_responses` / `ctx.request_state` (the [guard pattern](/servers/elicitation#elicitation-on-the-modern-protocol)). Each round is a complete request→response cycle; the framework seals `request_state` on the wire and unseals it before the tool runs, and a shared-key `request_state_security` policy carries state across replicas. On handshake-era connections returning this result produces a clear era error. |
|
||||
| **Spec-standard errors (SEP-2164)** | Missing-resource reads return `-32602`; push-feature calls on modern connections fail with clear era-specific errors rather than generic method-not-found. |
|
||||
| **Background tasks** | `@mcp.tool(task=True)` runs on a Redis-backed distributed runtime (Docket) with cross-replica notifications — execution infrastructure that is FastMCP's own, independent of the protocol-era task surface. |
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import secrets
|
|||
import ssl
|
||||
import uuid
|
||||
import weakref
|
||||
from collections.abc import Callable, Coroutine
|
||||
from collections.abc import Callable, Coroutine, Mapping, Sequence
|
||||
from contextlib import AsyncExitStack, asynccontextmanager, suppress
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
|
@ -31,7 +31,12 @@ from mcp.client.caching import (
|
|||
ClientResponseCache,
|
||||
InMemoryResponseCacheStore,
|
||||
)
|
||||
from mcp.client.extension import NotificationBinding
|
||||
from mcp.client.extension import (
|
||||
ClaimContext,
|
||||
ClientExtension,
|
||||
NotificationBinding,
|
||||
ResultClaim,
|
||||
)
|
||||
from mcp.client.session import ClientRequestContext, MessageHandlerFnT
|
||||
from mcp_types import (
|
||||
GetTaskResult,
|
||||
|
|
@ -121,11 +126,11 @@ CacheableT = TypeVar("CacheableT", bound=mcp_types.CacheableResult)
|
|||
ConnectMode = Literal["legacy", "auto"] | str
|
||||
"""How the client negotiates the protocol era at connect time.
|
||||
|
||||
- ``"legacy"`` (the current default): the classic initialize handshake, byte-identical
|
||||
to pre-v4 behavior for handshake-era servers.
|
||||
- ``"auto"``: probe ``server/discover`` at the newest modern version and adopt it, falling
|
||||
back to the initialize handshake for any server that is not positive evidence of a modern
|
||||
peer (a denylist fallback — see the SDK's ``negotiate_auto``).
|
||||
- ``"auto"`` (the default): probe ``server/discover`` at the newest modern version and
|
||||
adopt it, falling back to the initialize handshake for any server that is not positive
|
||||
evidence of a modern peer (a denylist fallback — see the SDK's ``negotiate_auto``).
|
||||
- ``"legacy"``: the classic initialize handshake, byte-identical to pre-v4 behavior for
|
||||
handshake-era servers. Opt into this to force the old handshake.
|
||||
- a modern protocol-version string (e.g. ``"2026-07-28"``): adopt that version directly
|
||||
without probing, synthesizing a minimal ``DiscoverResult`` when none is supplied.
|
||||
|
||||
|
|
@ -149,6 +154,92 @@ def _synthesize_discover(protocol_version: str) -> mcp_types.DiscoverResult:
|
|||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class _FoldedExtensions:
|
||||
"""`Client(extensions=...)` folded into the shapes `ClientSession` consumes.
|
||||
|
||||
`ad` maps each extension identifier to its advertised settings (the SEP-2133
|
||||
capability ad), `claims` maps each identifier to its `ResultClaim`s, `bindings`
|
||||
is the flat list of `NotificationBinding`s the extensions observe, and `by_model`
|
||||
indexes every claim by its result model so a claimed `tools/call` result can be
|
||||
routed back to the owning resolver.
|
||||
"""
|
||||
|
||||
ad: dict[str, dict[str, Any]]
|
||||
claims: dict[str, tuple[ResultClaim[Any], ...]]
|
||||
bindings: list[NotificationBinding[Any]]
|
||||
by_model: dict[type[mcp_types.Result], ResultClaim[Any]]
|
||||
|
||||
|
||||
def _fold_extensions(
|
||||
extensions: Sequence[ClientExtension] | None,
|
||||
) -> _FoldedExtensions:
|
||||
"""Decompose `ClientExtension` instances into `ClientSession` kwargs.
|
||||
|
||||
Mirrors the SDK Client's own folding, using only the public `ClientExtension`
|
||||
surface (`settings()`, `claims()`, `notifications()`). Duplicate identifiers,
|
||||
result-type tags, or notification methods across extensions raise here rather
|
||||
than at session construction, naming both owners. `by_model` is the model→claim
|
||||
index the resolution path uses to finish a claimed result.
|
||||
"""
|
||||
folded = _FoldedExtensions(ad={}, claims={}, bindings=[], by_model={})
|
||||
if not extensions:
|
||||
return folded
|
||||
if isinstance(extensions, Mapping):
|
||||
raise TypeError(
|
||||
"extensions= takes a sequence of ClientExtension instances; use "
|
||||
"mcp.client.advertise(identifier, settings) for an advertise-only entry"
|
||||
)
|
||||
claim_owners: dict[str, str] = {}
|
||||
binding_owners: dict[str, str] = {}
|
||||
for extension in extensions:
|
||||
identifier = getattr(extension, "identifier", None)
|
||||
if identifier is None:
|
||||
raise ValueError(
|
||||
f"{type(extension).__name__} has no `identifier`; a ClientExtension "
|
||||
"must set the `identifier` class attribute (or assign one in "
|
||||
"`__init__`) before it can be used"
|
||||
)
|
||||
if identifier in folded.ad:
|
||||
raise ValueError(
|
||||
f"extension identifier {identifier!r} is passed more than once"
|
||||
)
|
||||
folded.ad[identifier] = extension.settings()
|
||||
extension_claims = tuple(extension.claims())
|
||||
for claim in extension_claims:
|
||||
tag = claim.result_type
|
||||
if tag in claim_owners:
|
||||
owner = claim_owners[tag]
|
||||
both = (
|
||||
f"extension {identifier!r} claims"
|
||||
if owner == identifier
|
||||
else f"extensions {owner!r} and {identifier!r} both claim"
|
||||
)
|
||||
raise ValueError(
|
||||
f"{both} resultType {tag!r}; a wire tag can have only one resolver"
|
||||
)
|
||||
claim_owners[tag] = identifier
|
||||
# Each model pins its result_type Literal to one tag, so this cannot collide.
|
||||
folded.by_model[claim.model] = claim
|
||||
if extension_claims:
|
||||
folded.claims[identifier] = extension_claims
|
||||
for binding in extension.notifications():
|
||||
if binding.method in binding_owners:
|
||||
owner = binding_owners[binding.method]
|
||||
both = (
|
||||
f"extension {identifier!r} binds"
|
||||
if owner == identifier
|
||||
else f"extensions {owner!r} and {identifier!r} both bind"
|
||||
)
|
||||
raise ValueError(
|
||||
f"{both} notification method {binding.method!r}; a method can "
|
||||
"have only one observer"
|
||||
)
|
||||
binding_owners[binding.method] = identifier
|
||||
folded.bindings.append(binding)
|
||||
return folded
|
||||
|
||||
|
||||
def _evicting_message_handler(
|
||||
cache: ClientResponseCache, user_handler: MessageHandlerFnT | None
|
||||
) -> MessageHandlerFnT:
|
||||
|
|
@ -259,12 +350,13 @@ class Client(
|
|||
timeout: Optional timeout for requests (seconds or timedelta)
|
||||
init_timeout: Optional timeout for initial connection (seconds or timedelta).
|
||||
Set to 0 to disable. If None, uses the value in the FastMCP global settings.
|
||||
mode: Protocol-era negotiation at connect time. `"legacy"` (the default) runs
|
||||
the initialize handshake, byte-identical to pre-v4 behavior. `"auto"` probes
|
||||
mode: Protocol-era negotiation at connect time. `"auto"` (the default) probes
|
||||
`server/discover` and negotiates the modern era, denylist-falling-back to the
|
||||
handshake for legacy servers. A modern version string (e.g. `"2026-07-28"`)
|
||||
adopts that version directly. `mode="auto"` as a future default is a
|
||||
release-time decision; the conservative `"legacy"` is the default for now.
|
||||
initialize handshake for any server that is not positive evidence of a modern
|
||||
peer — safe against a mixed fleet of legacy and modern servers. `"legacy"`
|
||||
forces the initialize handshake, byte-identical to pre-v4 behavior; opt into it
|
||||
to pin the old handshake. A modern version string (e.g. `"2026-07-28"`) adopts
|
||||
that version directly without a probe.
|
||||
prior_discover: A previously obtained `DiscoverResult` to adopt when `mode` is a
|
||||
version pin, reused instead of synthesizing a minimal one. Ignored otherwise.
|
||||
input_required_max_rounds: Cap on `InputRequiredResult` (SEP-2322) retry rounds
|
||||
|
|
@ -276,6 +368,19 @@ class Client(
|
|||
modern-only, so a cache is inert on legacy connections. A custom `CacheConfig`
|
||||
store requires `target_id`, since FastMCP transports expose no server URL to
|
||||
derive a shared-store identity from.
|
||||
extensions: Opt-in client extensions (SEP-2133), a sequence of
|
||||
`mcp.client.extension.ClientExtension` instances. Each contributes its
|
||||
capability advertisement, its result claims, and its notification bindings,
|
||||
all of which are threaded into the underlying session. User-supplied
|
||||
notification bindings compose with FastMCP's internal task-status binding
|
||||
rather than replacing it. A claimed `call_tool` result is resolved
|
||||
transparently through the owning extension's resolver. For an advertise-only
|
||||
entry, use `mcp.client.advertise(identifier, settings)`.
|
||||
result_claims: Additional `ResultClaim`s (SEP-2133) keyed by the identifier of
|
||||
an extension already advertised through `extensions`, merged with that
|
||||
extension's own claims. Rarely needed directly; prefer declaring claims on
|
||||
the extension itself. Claimed shapes are modern-only and inert on a legacy
|
||||
connection.
|
||||
|
||||
Examples:
|
||||
```python
|
||||
|
|
@ -365,10 +470,12 @@ class Client(
|
|||
client_info: mcp_types.Implementation | None = None,
|
||||
auth: httpx2.Auth | Literal["oauth"] | str | None = None,
|
||||
verify: ssl.SSLContext | bool | str | None = None,
|
||||
mode: ConnectMode = "legacy",
|
||||
mode: ConnectMode = "auto",
|
||||
prior_discover: mcp_types.DiscoverResult | None = None,
|
||||
input_required_max_rounds: int = DEFAULT_INPUT_REQUIRED_MAX_ROUNDS,
|
||||
cache: CacheConfig | bool | None = None,
|
||||
extensions: Sequence[ClientExtension] | None = None,
|
||||
result_claims: Mapping[str, Sequence[ResultClaim[Any]]] | None = None,
|
||||
) -> None:
|
||||
self.name = name or self.generate_name()
|
||||
|
||||
|
|
@ -453,6 +560,14 @@ class Client(
|
|||
self._response_cache, effective_message_handler
|
||||
)
|
||||
|
||||
# Opt-in client extensions (SEP-2133) and their result claims. Retained so
|
||||
# `new()` can rebuild an independent set of session kwargs per clone.
|
||||
self._extensions_arg = extensions
|
||||
self._result_claims_arg = result_claims
|
||||
# Model→claim index the resolution path uses; (re)built by
|
||||
# `_build_extension_kwargs`.
|
||||
self._claim_by_model: dict[type[mcp_types.Result], ResultClaim[Any]] = {}
|
||||
|
||||
self._session_kwargs: SessionKwargs = {
|
||||
"sampling_callback": None,
|
||||
"list_roots_callback": None,
|
||||
|
|
@ -460,10 +575,7 @@ class Client(
|
|||
"message_handler": effective_message_handler,
|
||||
"read_timeout_seconds": read_timeout_seconds,
|
||||
"client_info": client_info,
|
||||
# SDK v2 does not carry `notifications/tasks/status` in any protocol
|
||||
# version's core notification tables, so it is never tee'd to the
|
||||
# message_handler; a binding routes it to Task objects instead.
|
||||
"notification_bindings": [self._task_status_binding()],
|
||||
**self._build_extension_kwargs(),
|
||||
}
|
||||
|
||||
if roots is not None:
|
||||
|
|
@ -687,10 +799,10 @@ class Client(
|
|||
)
|
||||
else:
|
||||
new_client._session_kwargs["message_handler"] = base_handler
|
||||
# Rebind the task-status notification binding so it routes to the clone.
|
||||
new_client._session_kwargs["notification_bindings"] = [
|
||||
new_client._task_status_binding()
|
||||
]
|
||||
# Rebuild the extension-contributed kwargs (capability ad, result claims,
|
||||
# notification bindings) so the clone's task-status binding routes to the
|
||||
# clone while user extensions still compose with it.
|
||||
new_client._session_kwargs.update(new_client._build_extension_kwargs())
|
||||
|
||||
new_client.name += f":{secrets.token_hex(2)}"
|
||||
|
||||
|
|
@ -745,13 +857,20 @@ class Client(
|
|||
else:
|
||||
timeout = normalize_timeout_to_seconds(timeout)
|
||||
|
||||
# A legacy-only transport (SSE, a multi-server proxy config) cannot serve
|
||||
# the modern era; treat "auto" as "legacy" there rather than probing
|
||||
# server/discover, which some such servers answer but then cannot serve.
|
||||
effective_mode = self.mode
|
||||
if effective_mode == "auto" and self.transport.legacy_only:
|
||||
effective_mode = "legacy"
|
||||
|
||||
try:
|
||||
with anyio.fail_after(timeout):
|
||||
if self.mode == "legacy":
|
||||
if effective_mode == "legacy":
|
||||
self._session_state.initialize_result = (
|
||||
await self.session.initialize()
|
||||
)
|
||||
elif self.mode == "auto":
|
||||
elif effective_mode == "auto":
|
||||
await negotiate_auto(self.session)
|
||||
# auto may have fallen back to the legacy handshake; surface its
|
||||
# InitializeResult through the existing public property when so.
|
||||
|
|
@ -782,7 +901,7 @@ class Client(
|
|||
With `mode="auto"` or a pinned modern version, connect-time negotiation may adopt
|
||||
the modern `server/discover` era, which has no `InitializeResult`; in that case
|
||||
this method raises. Read `protocol_version` / `server_capabilities` instead, or use
|
||||
`mode="legacy"` (the default) when you need the handshake result.
|
||||
`mode="legacy"` when you need the handshake result.
|
||||
|
||||
Args:
|
||||
timeout: Optional timeout for the initialization request (seconds or timedelta).
|
||||
|
|
@ -1170,6 +1289,73 @@ class Client(
|
|||
status = GetTaskResult.model_validate(params.model_dump())
|
||||
task._handle_status_notification(status)
|
||||
|
||||
def _build_extension_kwargs(self) -> SessionKwargs:
|
||||
"""Session kwargs contributed by `extensions=` / `result_claims=`.
|
||||
|
||||
Folds the user's `ClientExtension` instances into the capability ad, result
|
||||
claims, and notification bindings the SDK `ClientSession` consumes, then
|
||||
merges in any explicitly-passed `result_claims`. The internal task-status
|
||||
binding is always prepended to the folded bindings so user extensions
|
||||
*compose* with it rather than clobbering it; a user extension that binds the
|
||||
same `notifications/tasks/status` method surfaces a duplicate-method error
|
||||
from the SDK rather than silently replacing FastMCP's routing.
|
||||
|
||||
Also rebuilds `self._claim_by_model`, the model→claim index the resolution
|
||||
path uses to finish a claimed `tools/call` result, covering both the folded
|
||||
extension claims and the explicit `result_claims` extras.
|
||||
"""
|
||||
folded = _fold_extensions(self._extensions_arg)
|
||||
|
||||
claims: dict[str, tuple[ResultClaim[Any], ...]] = dict(folded.claims)
|
||||
by_model: dict[type[mcp_types.Result], ResultClaim[Any]] = dict(folded.by_model)
|
||||
for identifier, extra in (self._result_claims_arg or {}).items():
|
||||
existing = claims.get(identifier, ())
|
||||
claims[identifier] = (*existing, *extra)
|
||||
for claim in extra:
|
||||
by_model[claim.model] = claim
|
||||
self._claim_by_model = by_model
|
||||
|
||||
kwargs: SessionKwargs = {
|
||||
# The internal task binding must lead so user bindings extend it.
|
||||
"notification_bindings": [
|
||||
self._task_status_binding(),
|
||||
*folded.bindings,
|
||||
],
|
||||
}
|
||||
if folded.ad:
|
||||
kwargs["extensions"] = folded.ad
|
||||
if claims:
|
||||
kwargs["result_claims"] = claims
|
||||
return kwargs
|
||||
|
||||
async def _resolve_claimed_result(
|
||||
self,
|
||||
name: str,
|
||||
result: mcp_types.Result,
|
||||
read_timeout_seconds: float | None,
|
||||
) -> mcp_types.CallToolResult:
|
||||
"""Finish a claimed `tools/call` result through its owning extension.
|
||||
|
||||
A modern server may answer `tools/call` with a claimed extension shape
|
||||
(SEP-2133). The session parses it into the claim's model; this hands that
|
||||
model to the owning claim's resolver — which may send follow-up requests
|
||||
through the session — and returns the ordinary `CallToolResult` it
|
||||
produces. Mirrors the SDK Client's resolution path, including the
|
||||
output-schema revalidation the direct path performs.
|
||||
"""
|
||||
claim = self._claim_by_model[type(result)]
|
||||
final = await claim.resolve(
|
||||
result,
|
||||
ClaimContext(
|
||||
session=self.session,
|
||||
tool_name=name,
|
||||
read_timeout_seconds=read_timeout_seconds,
|
||||
),
|
||||
)
|
||||
if not final.is_error:
|
||||
await self.session.validate_tool_result(name, final)
|
||||
return final
|
||||
|
||||
def _task_status_binding(self) -> NotificationBinding[TaskStatusNotificationParams]:
|
||||
"""Build a binding routing `notifications/tasks/status` to Task objects.
|
||||
|
||||
|
|
|
|||
|
|
@ -195,10 +195,20 @@ class ClientToolsMixin:
|
|||
read_timeout_seconds = normalize_timeout_to_seconds(timeout)
|
||||
progress_callback = progress_handler or self._progress_handler
|
||||
|
||||
# Only opt into claimed results (SEP-2133) when this client registered
|
||||
# an extension that claims one; otherwise keep the SDK's default, which
|
||||
# surfaces an unexpected claimed result as an error rather than parsing
|
||||
# a shape we have no resolver for.
|
||||
has_claims = bool(self._claim_by_model)
|
||||
|
||||
async def _retry(
|
||||
input_responses: mcp_types.InputResponses | None,
|
||||
request_state: str | None,
|
||||
) -> mcp_types.CallToolResult | mcp_types.InputRequiredResult:
|
||||
) -> (
|
||||
mcp_types.CallToolResult
|
||||
| mcp_types.InputRequiredResult
|
||||
| mcp_types.Result
|
||||
):
|
||||
return await self.session.call_tool(
|
||||
name=name,
|
||||
arguments=arguments,
|
||||
|
|
@ -208,12 +218,21 @@ class ClientToolsMixin:
|
|||
input_responses=input_responses,
|
||||
request_state=request_state,
|
||||
allow_input_required=True,
|
||||
allow_claimed=has_claims,
|
||||
)
|
||||
|
||||
first = await self._await_with_session_monitoring(_retry(None, None))
|
||||
result = await self._await_with_session_monitoring(
|
||||
driven = await self._await_with_session_monitoring(
|
||||
self._drive_input_required(first, _retry)
|
||||
)
|
||||
if isinstance(driven, mcp_types.CallToolResult):
|
||||
result = driven
|
||||
else:
|
||||
# A claimed extension result (SEP-2133): resolve it through the
|
||||
# owning extension's resolver into an ordinary CallToolResult.
|
||||
result = await self._resolve_claimed_result(
|
||||
name, driven, read_timeout_seconds
|
||||
)
|
||||
|
||||
# Reflect tool-level errors on the span so callers see ERROR
|
||||
# status even though the MCP protocol call itself succeeded.
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
import abc
|
||||
import contextlib
|
||||
from collections.abc import AsyncIterator, Sequence
|
||||
from collections.abc import AsyncIterator, Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Literal, TypeVar
|
||||
|
||||
import httpx2
|
||||
import mcp_types
|
||||
from mcp import ClientSession
|
||||
from mcp.client.extension import NotificationBinding
|
||||
from mcp.client.extension import NotificationBinding, ResultClaim
|
||||
from mcp.client.session import (
|
||||
ElicitationFnT,
|
||||
ListRootsFnT,
|
||||
|
|
@ -33,6 +33,8 @@ class ClientSessionKwargs(TypedDict, total=False):
|
|||
message_handler: MessageHandlerFnT | None
|
||||
client_info: mcp_types.Implementation | None
|
||||
notification_bindings: Sequence[NotificationBinding[Any]] | None
|
||||
extensions: dict[str, dict[str, Any]] | None
|
||||
result_claims: Mapping[str, Sequence[ResultClaim[Any]]] | None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
|
|
@ -70,6 +72,13 @@ class ClientTransport(abc.ABC):
|
|||
|
||||
"""
|
||||
|
||||
#: Whether this transport can only carry the legacy (handshake) protocol era.
|
||||
#: The modern `2026-07-28` era is sessionless and served over Streamable HTTP;
|
||||
#: the SSE transport predates it and cannot serve it. When True, a client with
|
||||
#: `mode="auto"` negotiates the legacy handshake directly rather than probing
|
||||
#: `server/discover` (which some servers answer over SSE but then cannot serve).
|
||||
legacy_only: bool = False
|
||||
|
||||
@abc.abstractmethod
|
||||
@contextlib.asynccontextmanager
|
||||
async def connect_session(
|
||||
|
|
|
|||
|
|
@ -91,6 +91,21 @@ class MCPConfigTransport(ClientTransport):
|
|||
self.transport = next(iter(self.config.mcpServers.values())).to_transport()
|
||||
self._transports.append(self.transport)
|
||||
|
||||
@property
|
||||
def legacy_only(self) -> bool:
|
||||
"""Whether this config can only carry the legacy protocol era.
|
||||
|
||||
A single-server config delegates directly to the underlying transport
|
||||
(no proxy), so it inherits that transport's era capability — a modern
|
||||
Streamable HTTP backend must stay modern-capable under `mode="auto"`.
|
||||
A multi-server config mounts each backend behind a legacy-era
|
||||
`ProxyClient` on a composite server, so the composite it exposes is
|
||||
legacy-era and `mode="auto"` should negotiate the handshake.
|
||||
"""
|
||||
if len(self.config.mcpServers) == 1:
|
||||
return self.transport.legacy_only
|
||||
return True
|
||||
|
||||
@contextlib.asynccontextmanager
|
||||
async def connect_session(
|
||||
self,
|
||||
|
|
|
|||
|
|
@ -29,6 +29,10 @@ from fastmcp.utilities.timeout import normalize_timeout_to_timedelta
|
|||
class SSETransport(ClientTransport):
|
||||
"""Transport implementation that connects to an MCP server via Server-Sent Events."""
|
||||
|
||||
# SSE predates the sessionless modern era and cannot serve it; a client with
|
||||
# `mode="auto"` negotiates the legacy handshake directly over SSE.
|
||||
legacy_only = True
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
url: str | AnyUrl,
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -1220,7 +1220,10 @@ class ProxyClient(Client[ClientTransportT]):
|
|||
# via the handlers installed below) that proxies rely on. To round-trip
|
||||
# an upstream guard tool's InputRequiredResult (SEP-2322) instead, opt
|
||||
# into the modern era explicitly with `create_proxy(target, mode="auto")`
|
||||
# — the two are mutually exclusive per session.
|
||||
# — the two are mutually exclusive per session. This pin is explicit
|
||||
# rather than inherited, so flipping `Client`'s own default to `"auto"`
|
||||
# never changes proxy behavior.
|
||||
kwargs.setdefault("mode", "legacy")
|
||||
# Install context-restoring handler wrappers BEFORE super().__init__
|
||||
# registers them with the Client's session kwargs.
|
||||
self._proxy_rc_ref = [None]
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -101,10 +101,19 @@ async def test_unauthorized(client_unauthorized: Client):
|
|||
pass
|
||||
|
||||
|
||||
async def test_ping(client_with_headless_oauth: Client):
|
||||
"""Test that we can ping the server."""
|
||||
async with client_with_headless_oauth:
|
||||
assert await client_with_headless_oauth.ping()
|
||||
async def test_ping(streamable_http_server: str):
|
||||
"""Test that we can ping the server.
|
||||
|
||||
Pinned to legacy: `ping` is a handshake-era request removed from the modern
|
||||
(2026-07-28) protocol.
|
||||
"""
|
||||
client = Client(
|
||||
transport=StreamableHttpTransport(streamable_http_server),
|
||||
auth=HeadlessOAuth(mcp_url=streamable_http_server, scopes=["read", "write"]),
|
||||
mode="legacy",
|
||||
)
|
||||
async with client:
|
||||
assert await client.ping()
|
||||
|
||||
|
||||
async def test_list_tools(client_with_headless_oauth: Client):
|
||||
|
|
@ -168,9 +177,12 @@ async def test_expired_dynamic_registration_is_retried():
|
|||
server = FastMCP("TestServer", auth=provider)
|
||||
|
||||
async with run_server_async(server, port=port, transport="http") as url:
|
||||
# Pinned to legacy: `ping` is a handshake-era request removed from the
|
||||
# modern (2026-07-28) protocol; the retry is exercised via the handshake.
|
||||
client = Client(
|
||||
transport=StreamableHttpTransport(url),
|
||||
auth=HeadlessOAuth(mcp_url=url),
|
||||
mode="legacy",
|
||||
)
|
||||
async with client:
|
||||
assert await client.ping()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -273,7 +273,12 @@ async def test_client_serialization_error():
|
|||
|
||||
|
||||
async def test_server_deserialization_error():
|
||||
"""Test server error when JSON string cannot be converted to expected type."""
|
||||
"""Test server error when JSON string cannot be converted to expected type.
|
||||
|
||||
Pinned to legacy: the detailed `PromptError` message is surfaced to the
|
||||
client only on the handshake era; the modern server runner reports the
|
||||
raised conversion error as a generic "Internal server error".
|
||||
"""
|
||||
|
||||
server = FastMCP("TestServer")
|
||||
|
||||
|
|
@ -282,7 +287,7 @@ async def test_server_deserialization_error():
|
|||
"""Expects list of integers but will receive invalid JSON."""
|
||||
return f"Got {len(numbers)} numbers"
|
||||
|
||||
client = Client(transport=FastMCPTransport(server))
|
||||
client = Client(transport=FastMCPTransport(server), mode="legacy")
|
||||
|
||||
async with client:
|
||||
with pytest.raises(MCPError, match="Could not convert argument"):
|
||||
|
|
@ -341,7 +346,7 @@ async def test_read_resource_mcp(fastmcp_server):
|
|||
|
||||
async def test_client_connection(fastmcp_server):
|
||||
"""Test that connect is idempotent."""
|
||||
client = Client(transport=FastMCPTransport(fastmcp_server))
|
||||
client = Client(transport=FastMCPTransport(fastmcp_server), mode="legacy")
|
||||
|
||||
# Connect idempotently
|
||||
async with client:
|
||||
|
|
@ -353,7 +358,7 @@ async def test_client_connection(fastmcp_server):
|
|||
|
||||
async def test_initialize_called_once(fastmcp_server):
|
||||
"""Test that initialization is called once and sets initialize_result."""
|
||||
client = Client(transport=FastMCPTransport(fastmcp_server))
|
||||
client = Client(transport=FastMCPTransport(fastmcp_server), mode="legacy")
|
||||
async with client:
|
||||
# Verify that initialization succeeded by checking initialize_result
|
||||
assert client.initialize_result is not None
|
||||
|
|
@ -362,7 +367,7 @@ async def test_initialize_called_once(fastmcp_server):
|
|||
|
||||
async def test_initialize_result_connected(fastmcp_server):
|
||||
"""Test that initialize_result returns the correct result when connected."""
|
||||
client = Client(transport=FastMCPTransport(fastmcp_server))
|
||||
client = Client(transport=FastMCPTransport(fastmcp_server), mode="legacy")
|
||||
|
||||
# Initialize result should be None before connection
|
||||
assert client.initialize_result is None
|
||||
|
|
@ -397,7 +402,7 @@ async def test_server_info_custom_version():
|
|||
"""Test that custom version is properly set in serverInfo."""
|
||||
# Test with custom version
|
||||
server_with_version = FastMCP("CustomVersionServer", version="1.2.3")
|
||||
client = Client(transport=FastMCPTransport(server_with_version))
|
||||
client = Client(transport=FastMCPTransport(server_with_version), mode="legacy")
|
||||
|
||||
async with client:
|
||||
result = client.initialize_result
|
||||
|
|
@ -407,7 +412,7 @@ async def test_server_info_custom_version():
|
|||
|
||||
# Test without version (backward compatibility)
|
||||
server_without_version = FastMCP("DefaultVersionServer")
|
||||
client = Client(transport=FastMCPTransport(server_without_version))
|
||||
client = Client(transport=FastMCPTransport(server_without_version), mode="legacy")
|
||||
|
||||
async with client:
|
||||
result = client.initialize_result
|
||||
|
|
|
|||
|
|
@ -1,4 +1,13 @@
|
|||
"""Client error handling tests."""
|
||||
"""Client error handling tests.
|
||||
|
||||
Resource, resource-template, and prompt error *detail* surfacing is a
|
||||
handshake-era behavior: the legacy read/get path converts a `ResourceError` /
|
||||
`PromptError` into a client-visible message, while the modern (2026-07-28)
|
||||
server runner surfaces the raised exception as a generic "Internal server
|
||||
error". Tests asserting the detailed message are pinned to `mode="legacy"`;
|
||||
tool-error tests (which flow through an `isError` `CallToolResult`) are
|
||||
era-neutral and run on the default `auto`.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
|
|
@ -85,7 +94,7 @@ class TestErrorHandling:
|
|||
async def exception_resource():
|
||||
raise ValueError("This is an internal error (sensitive)")
|
||||
|
||||
client = Client(transport=FastMCPTransport(mcp))
|
||||
client = Client(transport=FastMCPTransport(mcp), mode="legacy")
|
||||
|
||||
async with client:
|
||||
with pytest.raises(Exception) as excinfo:
|
||||
|
|
@ -101,7 +110,7 @@ class TestErrorHandling:
|
|||
async def exception_resource():
|
||||
raise ValueError("This is an internal error (sensitive)")
|
||||
|
||||
client = Client(transport=FastMCPTransport(mcp))
|
||||
client = Client(transport=FastMCPTransport(mcp), mode="legacy")
|
||||
|
||||
async with client:
|
||||
with pytest.raises(Exception) as excinfo:
|
||||
|
|
@ -117,7 +126,7 @@ class TestErrorHandling:
|
|||
async def error_resource():
|
||||
raise ResourceError("This is a resource error (xyz)")
|
||||
|
||||
client = Client(transport=FastMCPTransport(mcp))
|
||||
client = Client(transport=FastMCPTransport(mcp), mode="legacy")
|
||||
|
||||
async with client:
|
||||
with pytest.raises(Exception) as excinfo:
|
||||
|
|
@ -131,7 +140,7 @@ class TestErrorHandling:
|
|||
async def exception_resource(id: str):
|
||||
raise ValueError("This is an internal error (sensitive)")
|
||||
|
||||
client = Client(transport=FastMCPTransport(mcp))
|
||||
client = Client(transport=FastMCPTransport(mcp), mode="legacy")
|
||||
|
||||
async with client:
|
||||
with pytest.raises(Exception) as excinfo:
|
||||
|
|
@ -147,7 +156,7 @@ class TestErrorHandling:
|
|||
async def exception_resource(id: str):
|
||||
raise ValueError("This is an internal error (sensitive)")
|
||||
|
||||
client = Client(transport=FastMCPTransport(mcp))
|
||||
client = Client(transport=FastMCPTransport(mcp), mode="legacy")
|
||||
|
||||
async with client:
|
||||
with pytest.raises(Exception) as excinfo:
|
||||
|
|
@ -163,7 +172,7 @@ class TestErrorHandling:
|
|||
async def error_resource(id: str):
|
||||
raise ResourceError("This is a resource error (xyz)")
|
||||
|
||||
client = Client(transport=FastMCPTransport(mcp))
|
||||
client = Client(transport=FastMCPTransport(mcp), mode="legacy")
|
||||
|
||||
async with client:
|
||||
with pytest.raises(Exception) as excinfo:
|
||||
|
|
@ -326,7 +335,7 @@ class TestLogLevel:
|
|||
"Resource unavailable, try again later", log_level=logging.WARNING
|
||||
)
|
||||
|
||||
async with Client(transport=FastMCPTransport(mcp)) as client:
|
||||
async with Client(transport=FastMCPTransport(mcp), mode="legacy") as client:
|
||||
with caplog.at_level(logging.WARNING):
|
||||
with pytest.raises(Exception) as exc_info:
|
||||
await client.read_resource_mcp("test://custom")
|
||||
|
|
@ -349,7 +358,7 @@ class TestLogLevel:
|
|||
def regular_resource():
|
||||
raise ResourceError("Something went wrong")
|
||||
|
||||
async with Client(transport=FastMCPTransport(mcp)) as client:
|
||||
async with Client(transport=FastMCPTransport(mcp), mode="legacy") as client:
|
||||
with caplog.at_level(logging.ERROR):
|
||||
with pytest.raises(Exception) as exc_info:
|
||||
await client.read_resource_mcp("test://regular")
|
||||
|
|
@ -371,7 +380,7 @@ class TestLogLevel:
|
|||
"Insufficient context, provide more details", log_level=logging.WARNING
|
||||
)
|
||||
|
||||
async with Client(transport=FastMCPTransport(mcp)) as client:
|
||||
async with Client(transport=FastMCPTransport(mcp), mode="legacy") as client:
|
||||
with caplog.at_level(logging.WARNING):
|
||||
with pytest.raises(Exception) as exc_info:
|
||||
await client.get_prompt("custom_level_prompt")
|
||||
|
|
@ -394,7 +403,7 @@ class TestLogLevel:
|
|||
def regular_prompt():
|
||||
raise PromptError("Something went wrong")
|
||||
|
||||
async with Client(transport=FastMCPTransport(mcp)) as client:
|
||||
async with Client(transport=FastMCPTransport(mcp), mode="legacy") as client:
|
||||
with caplog.at_level(logging.ERROR):
|
||||
with pytest.raises(Exception) as exc_info:
|
||||
await client.get_prompt("regular_prompt")
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -4,25 +4,36 @@ FastMCP serves both protocol eras from one server object over the in-memory
|
|||
stream loop (``serve_dual_era_loop``), so a single ``fastmcp_server`` fixture can
|
||||
be driven legacy or modern by varying ``mode=`` alone:
|
||||
|
||||
* ``mode="legacy"`` (the current default) runs the initialize handshake and
|
||||
reports the handshake-era version, byte-identically to pre-v4 behavior.
|
||||
* ``mode="auto"`` probes ``server/discover`` and negotiates the modern era.
|
||||
* ``mode="auto"`` (the default) probes ``server/discover`` and negotiates the
|
||||
modern era, denylist-falling-back to the initialize handshake for any server
|
||||
that is not positive evidence of a modern peer.
|
||||
* ``mode="legacy"`` runs the initialize handshake and reports the handshake-era
|
||||
version, byte-identically to pre-v4 behavior.
|
||||
* ``mode="2026-07-28"`` pins the modern version and adopts a synthesized
|
||||
``DiscoverResult`` without a probe.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
from collections.abc import AsyncIterator
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from mcp import ClientSession
|
||||
from mcp.shared.exceptions import MCPError
|
||||
from mcp_types import METHOD_NOT_FOUND
|
||||
from mcp_types.version import LATEST_HANDSHAKE_VERSION, LATEST_MODERN_VERSION
|
||||
from typing_extensions import Unpack
|
||||
|
||||
from fastmcp import Client, FastMCP
|
||||
from fastmcp.client.transports import FastMCPTransport, SessionKwargs
|
||||
|
||||
|
||||
class TestModeValidation:
|
||||
def test_default_mode_is_legacy(self, fastmcp_server):
|
||||
"""The conservative default is 'legacy' (see the v4 phasing note)."""
|
||||
assert Client(fastmcp_server).mode == "legacy"
|
||||
def test_default_mode_is_auto(self, fastmcp_server):
|
||||
"""The default is 'auto': probe server/discover, fall back to the handshake."""
|
||||
assert Client(fastmcp_server).mode == "auto"
|
||||
|
||||
@pytest.mark.parametrize("mode", ["legacy", "auto", LATEST_MODERN_VERSION])
|
||||
def test_valid_modes_accepted(self, fastmcp_server, mode):
|
||||
|
|
@ -47,12 +58,6 @@ class TestLegacyMode:
|
|||
assert client.initialize_result.server_info.name == "TestServer"
|
||||
assert client.server_capabilities is not None
|
||||
|
||||
async def test_default_matches_legacy(self, fastmcp_server):
|
||||
"""Omitting mode= is byte-identical to mode='legacy'."""
|
||||
async with Client(fastmcp_server) as client:
|
||||
assert client.protocol_version == LATEST_HANDSHAKE_VERSION
|
||||
assert client.initialize_result is not None
|
||||
|
||||
async def test_legacy_call_tool(self, fastmcp_server):
|
||||
async with Client(fastmcp_server, mode="legacy") as client:
|
||||
result = await client.call_tool("add", {"a": 2, "b": 3})
|
||||
|
|
@ -73,14 +78,18 @@ class TestAutoMode:
|
|||
result = await client.call_tool("add", {"a": 4, "b": 5})
|
||||
assert result.data == 9
|
||||
|
||||
async def test_auto_falls_back_to_legacy_for_handshake_only_server(self):
|
||||
"""A server that only speaks the handshake era makes auto denylist-fall-back to
|
||||
initialize, which still populates the InitializeResult.
|
||||
async def test_default_matches_auto(self, fastmcp_server):
|
||||
"""Omitting mode= is identical to mode='auto': modern via server/discover."""
|
||||
async with Client(fastmcp_server) as client:
|
||||
assert client.protocol_version == LATEST_MODERN_VERSION
|
||||
assert client.initialize_result is None
|
||||
|
||||
FastMCP always serves both eras, so this is characterized against the
|
||||
real dual-era server: auto reaches modern here. The fallback denylist
|
||||
itself is exercised by the SDK's own ``negotiate_auto`` suite; this cell
|
||||
documents the FastMCP-observable outcome.
|
||||
async def test_auto_reaches_modern_for_dual_era_server(self):
|
||||
"""FastMCP always serves both eras, so auto reaches modern here.
|
||||
|
||||
The fallback denylist itself is exercised by the SDK's own
|
||||
``negotiate_auto`` suite; this cell documents the FastMCP-observable
|
||||
outcome for a plain server.
|
||||
"""
|
||||
mcp = FastMCP("both-eras")
|
||||
|
||||
|
|
@ -91,6 +100,61 @@ class TestAutoMode:
|
|||
async with Client(mcp, mode="auto") as client:
|
||||
assert client.protocol_version == LATEST_MODERN_VERSION
|
||||
|
||||
async def test_auto_falls_back_cleanly_when_discover_is_rejected(
|
||||
self, fastmcp_server
|
||||
):
|
||||
"""A server that rejects the server/discover probe with a JSON-RPC error
|
||||
(e.g. a non-FastMCP legacy server that doesn't implement discover) makes
|
||||
auto fall back to the initialize handshake, cleanly — no error surfaces
|
||||
and the legacy InitializeResult is populated.
|
||||
|
||||
This characterizes the FastMCP-observable outcome of the SDK's
|
||||
denylist fallback (`negotiate_auto`): every RPC error except a
|
||||
disjoint modern-only -32022 falls back to `initialize()`.
|
||||
"""
|
||||
|
||||
class _DiscoverRejectingTransport(FastMCPTransport):
|
||||
"""Wraps the in-memory transport but rejects server/discover."""
|
||||
|
||||
@contextlib.asynccontextmanager
|
||||
async def connect_session(
|
||||
self, **session_kwargs: Unpack[SessionKwargs]
|
||||
) -> AsyncIterator[ClientSession]:
|
||||
async with super().connect_session(**session_kwargs) as session:
|
||||
|
||||
async def _reject_discover(version: str) -> dict[str, Any]:
|
||||
raise MCPError(
|
||||
code=METHOD_NOT_FOUND, message="Method not found"
|
||||
)
|
||||
|
||||
session.send_discover = _reject_discover # ty: ignore[invalid-assignment]
|
||||
yield session
|
||||
|
||||
transport = _DiscoverRejectingTransport(fastmcp_server)
|
||||
async with Client(transport, mode="auto") as client:
|
||||
# Fell back to the handshake: legacy version + populated InitializeResult.
|
||||
assert client.protocol_version == LATEST_HANDSHAKE_VERSION
|
||||
assert client.initialize_result is not None
|
||||
result = await client.call_tool("add", {"a": 1, "b": 2})
|
||||
assert result.data == 3
|
||||
|
||||
async def test_auto_uses_legacy_on_legacy_only_transport(self, fastmcp_server):
|
||||
"""A `legacy_only` transport (e.g. SSE) negotiates the handshake under auto.
|
||||
|
||||
SSE cannot serve the sessionless modern era, so a client with the default
|
||||
`mode="auto"` must run the initialize handshake directly rather than
|
||||
probing server/discover (which the FastMCP server answers even over SSE
|
||||
but then cannot serve).
|
||||
"""
|
||||
|
||||
class _LegacyOnlyTransport(FastMCPTransport):
|
||||
legacy_only = True
|
||||
|
||||
transport = _LegacyOnlyTransport(fastmcp_server)
|
||||
async with Client(transport, mode="auto") as client:
|
||||
assert client.protocol_version == LATEST_HANDSHAKE_VERSION
|
||||
assert client.initialize_result is not None
|
||||
|
||||
|
||||
class TestPinnedMode:
|
||||
async def test_pinned_modern_adopts_without_probe(self, fastmcp_server):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@ async def task_notification_server():
|
|||
|
||||
async def test_task_receives_status_notification(task_notification_server):
|
||||
"""Task object receives and processes status notifications."""
|
||||
async with Client(task_notification_server) as client:
|
||||
async with Client(task_notification_server, mode="legacy") as client:
|
||||
task = await client.call_tool("quick_task", {"value": 5}, task=True)
|
||||
|
||||
# Wait for task to complete (notification should arrive)
|
||||
|
|
@ -60,7 +60,7 @@ async def test_task_receives_status_notification(task_notification_server):
|
|||
|
||||
async def test_status_cache_updated_by_notification(task_notification_server):
|
||||
"""Cached status is updated when notification arrives."""
|
||||
async with Client(task_notification_server) as client:
|
||||
async with Client(task_notification_server, mode="legacy") as client:
|
||||
task = await client.call_tool("quick_task", {"value": 10}, task=True)
|
||||
|
||||
# Wait for completion (notification should update cache)
|
||||
|
|
@ -84,7 +84,7 @@ async def test_callback_invoked_on_notification(task_notification_server):
|
|||
"""Sync callback."""
|
||||
callback_invocations.append(status)
|
||||
|
||||
async with Client(task_notification_server) as client:
|
||||
async with Client(task_notification_server, mode="legacy") as client:
|
||||
task = await client.call_tool("quick_task", {"value": 7}, task=True)
|
||||
|
||||
# Register callback
|
||||
|
|
@ -113,7 +113,7 @@ async def test_async_callback_invoked(task_notification_server):
|
|||
await asyncio.sleep(0.01) # Simulate async work
|
||||
callback_invocations.append(status)
|
||||
|
||||
async with Client(task_notification_server) as client:
|
||||
async with Client(task_notification_server, mode="legacy") as client:
|
||||
task = await client.call_tool("quick_task", {"value": 3}, task=True)
|
||||
|
||||
# Register async callback
|
||||
|
|
@ -140,7 +140,7 @@ async def test_multiple_callbacks_all_invoked(task_notification_server):
|
|||
def callback2(status: GetTaskResult):
|
||||
callback2_calls.append(status.status)
|
||||
|
||||
async with Client(task_notification_server) as client:
|
||||
async with Client(task_notification_server, mode="legacy") as client:
|
||||
task = await client.call_tool("quick_task", {"value": 8}, task=True)
|
||||
|
||||
task.on_status_change(callback1)
|
||||
|
|
@ -166,7 +166,7 @@ async def test_callback_error_doesnt_break_notification(task_notification_server
|
|||
def working_callback(status: GetTaskResult):
|
||||
callback2_calls.append(status.status)
|
||||
|
||||
async with Client(task_notification_server) as client:
|
||||
async with Client(task_notification_server, mode="legacy") as client:
|
||||
task = await client.call_tool("quick_task", {"value": 12}, task=True)
|
||||
|
||||
task.on_status_change(failing_callback)
|
||||
|
|
@ -184,7 +184,7 @@ async def test_callback_error_doesnt_break_notification(task_notification_server
|
|||
|
||||
async def test_wait_wakes_early_on_notification(task_notification_server):
|
||||
"""wait() wakes up immediately when notification arrives, not after poll interval."""
|
||||
async with Client(task_notification_server) as client:
|
||||
async with Client(task_notification_server, mode="legacy") as client:
|
||||
task = await client.call_tool("quick_task", {"value": 15}, task=True)
|
||||
|
||||
# Record timing
|
||||
|
|
@ -201,7 +201,7 @@ async def test_wait_wakes_early_on_notification(task_notification_server):
|
|||
|
||||
async def test_notification_with_failed_task(task_notification_server):
|
||||
"""Notifications work for failed tasks too."""
|
||||
async with Client(task_notification_server) as client:
|
||||
async with Client(task_notification_server, mode="legacy") as client:
|
||||
task = await client.call_tool("failing_task", {}, task=True)
|
||||
|
||||
with pytest.raises(Exception):
|
||||
|
|
@ -232,7 +232,7 @@ async def test_fast_task_completion_delivered_via_notification(
|
|||
"""
|
||||
received: list[str] = []
|
||||
|
||||
async with Client(task_notification_server) as client:
|
||||
async with Client(task_notification_server, mode="legacy") as client:
|
||||
task = await client.call_tool("instant_task", {"value": 21}, task=True)
|
||||
task.on_status_change(lambda status: received.append(status.status))
|
||||
|
||||
|
|
@ -247,7 +247,7 @@ async def test_fast_task_completion_delivered_via_notification(
|
|||
|
||||
async def test_wait_returns_on_input_required(task_notification_server):
|
||||
"""wait() should return immediately when task enters input_required, not hang."""
|
||||
async with Client(task_notification_server) as client:
|
||||
async with Client(task_notification_server, mode="legacy") as client:
|
||||
task = await client.call_tool("quick_task", {"value": 1}, task=True)
|
||||
|
||||
# Directly inject an input_required status into the cache and signal the event.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ async def test_list_tasks_creates_propagating_client_span(
|
|||
):
|
||||
server = FastMCP("test-server")
|
||||
|
||||
async with Client(server) as client:
|
||||
async with Client(server, mode="legacy") as client:
|
||||
await client.list_tasks()
|
||||
|
||||
assert_propagating_client_span(trace_exporter, "tasks/list", "")
|
||||
|
|
@ -74,7 +74,7 @@ async def test_task_id_operations_create_propagating_client_spans(
|
|||
await asyncio.sleep(10)
|
||||
return "done"
|
||||
|
||||
async with Client(server) as client:
|
||||
async with Client(server, mode="legacy") as client:
|
||||
completed_task = await client.call_tool("quick_tool", task=True)
|
||||
await completed_task.wait(timeout=2)
|
||||
trace_exporter.clear()
|
||||
|
|
|
|||
|
|
@ -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"})
|
||||
|
||||
|
|
|
|||
331
tests/client/test_client_extensions.py
Normal file
331
tests/client/test_client_extensions.py
Normal file
|
|
@ -0,0 +1,331 @@
|
|||
"""Tests for surfacing SEP-2133 client extensions on ``fastmcp.Client``.
|
||||
|
||||
Covers that ``extensions=`` / ``result_claims=`` are folded into the underlying
|
||||
``ClientSession`` kwargs on construction, that user-supplied notification
|
||||
bindings *compose* with FastMCP's internal task-status binding rather than
|
||||
clobbering it, that both bindings actually fire against a live server, and that
|
||||
a claimed ``tools/call`` result is resolved end-to-end through the owning
|
||||
extension's resolver.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from typing import Any, Literal
|
||||
|
||||
import pytest
|
||||
from mcp.client.extension import (
|
||||
ClaimContext,
|
||||
ClientExtension,
|
||||
NotificationBinding,
|
||||
ResultClaim,
|
||||
UnexpectedClaimedResult,
|
||||
)
|
||||
from mcp.server.context import CallNext, HandlerResult, ServerRequestContext
|
||||
from mcp.server.extension import Extension
|
||||
from mcp.server.mcpserver import MCPServer as SDKServer
|
||||
from mcp_types import CallToolRequestParams, CallToolResult, Result, TextContent
|
||||
from mcp_types.version import LATEST_MODERN_VERSION
|
||||
from pydantic import BaseModel
|
||||
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.client import Client
|
||||
from fastmcp.server.dependencies import get_context
|
||||
|
||||
CUSTOM_METHOD = "notifications/x-test/ping"
|
||||
TASK_STATUS_METHOD = "notifications/tasks/status"
|
||||
EXTENSION_ID = "test.example.com/demo"
|
||||
CLAIMED_TYPE = "x-test/claimed"
|
||||
|
||||
|
||||
class PingParams(BaseModel):
|
||||
value: int = 0
|
||||
|
||||
|
||||
class ClaimedResult(Result):
|
||||
result_type: Literal["x-test/claimed"]
|
||||
payload: str = ""
|
||||
|
||||
|
||||
async def _resolve_claimed(result: ClaimedResult, ctx: ClaimContext) -> CallToolResult:
|
||||
"""Finish a claimed result into an ordinary CallToolResult.
|
||||
|
||||
Echoes the claimed payload so a test can prove the resolver ran on the
|
||||
server-emitted value rather than a placeholder.
|
||||
"""
|
||||
return CallToolResult(
|
||||
content=[TextContent(type="text", text=f"resolved:{result.payload}")]
|
||||
)
|
||||
|
||||
|
||||
def _make_claim() -> ResultClaim[ClaimedResult]:
|
||||
return ResultClaim(
|
||||
result_type=CLAIMED_TYPE,
|
||||
model=ClaimedResult,
|
||||
resolve=_resolve_claimed,
|
||||
)
|
||||
|
||||
|
||||
class _DemoExtension(ClientExtension):
|
||||
"""Extension contributing a settings ad, a result claim, and a binding."""
|
||||
|
||||
identifier = EXTENSION_ID
|
||||
|
||||
def __init__(self, received: list[PingParams] | None = None) -> None:
|
||||
self._received = received if received is not None else []
|
||||
|
||||
def settings(self) -> dict[str, Any]:
|
||||
return {"enabled": True}
|
||||
|
||||
def claims(self):
|
||||
return (_make_claim(),)
|
||||
|
||||
def notifications(self):
|
||||
async def _handler(params: PingParams) -> None:
|
||||
self._received.append(params)
|
||||
|
||||
return (
|
||||
NotificationBinding(
|
||||
method=CUSTOM_METHOD,
|
||||
params_type=PingParams,
|
||||
handler=_handler,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class _ServerClaimExtension(Extension):
|
||||
"""Server-side extension that answers a specific tool with a claimed shape."""
|
||||
|
||||
identifier = EXTENSION_ID
|
||||
|
||||
async def intercept_tool_call(
|
||||
self,
|
||||
params: CallToolRequestParams,
|
||||
ctx: ServerRequestContext[Any, Any],
|
||||
call_next: CallNext,
|
||||
) -> HandlerResult:
|
||||
if params.name == "claimed_tool":
|
||||
return ClaimedResult(result_type=CLAIMED_TYPE, payload="from-server")
|
||||
return await call_next(ctx)
|
||||
|
||||
|
||||
def _claiming_server() -> SDKServer:
|
||||
"""An SDK MCPServer whose `claimed_tool` returns a claimed extension result."""
|
||||
server = SDKServer("claim-server", extensions=[_ServerClaimExtension()])
|
||||
|
||||
# No return annotation → no output schema, so the resolved CallToolResult
|
||||
# (plain text, no structured content) passes revalidation.
|
||||
@server.tool()
|
||||
def claimed_tool():
|
||||
return None
|
||||
|
||||
return server
|
||||
|
||||
|
||||
def _binding_methods(client: Client) -> list[str]:
|
||||
bindings = client._session_kwargs.get("notification_bindings") or []
|
||||
return [b.method for b in bindings]
|
||||
|
||||
|
||||
def test_extension_folds_into_session_kwargs():
|
||||
"""A ClientExtension's ad, claim, and binding reach the session kwargs."""
|
||||
client = Client(FastMCP("srv"), extensions=[_DemoExtension()])
|
||||
|
||||
assert client._session_kwargs.get("extensions") == {EXTENSION_ID: {"enabled": True}}
|
||||
result_claims = client._session_kwargs.get("result_claims")
|
||||
assert result_claims is not None
|
||||
assert [c.result_type for c in result_claims[EXTENSION_ID]] == [CLAIMED_TYPE]
|
||||
|
||||
|
||||
def test_extension_populates_claim_by_model_index():
|
||||
"""The claim is indexed by its model so the resolution path can find it."""
|
||||
client = Client(FastMCP("srv"), extensions=[_DemoExtension()])
|
||||
|
||||
assert client._claim_by_model[ClaimedResult].result_type == CLAIMED_TYPE
|
||||
|
||||
|
||||
def test_binding_composes_with_internal_task_binding():
|
||||
"""User binding is appended to (not replacing) the task-status binding."""
|
||||
client = Client(FastMCP("srv"), extensions=[_DemoExtension()])
|
||||
|
||||
methods = _binding_methods(client)
|
||||
assert TASK_STATUS_METHOD in methods
|
||||
assert CUSTOM_METHOD in methods
|
||||
# The internal task binding must lead so user bindings extend it.
|
||||
assert methods[0] == TASK_STATUS_METHOD
|
||||
|
||||
|
||||
def test_no_extensions_leaves_only_task_binding():
|
||||
"""Without extensions, only the internal task-status binding is registered."""
|
||||
client = Client(FastMCP("srv"))
|
||||
|
||||
assert _binding_methods(client) == [TASK_STATUS_METHOD]
|
||||
assert "extensions" not in client._session_kwargs
|
||||
assert "result_claims" not in client._session_kwargs
|
||||
assert client._claim_by_model == {}
|
||||
|
||||
|
||||
def test_new_preserves_extension_composition():
|
||||
"""new() rebuilds the clone with both the task binding and user bindings."""
|
||||
client = Client(FastMCP("srv"), extensions=[_DemoExtension()])
|
||||
clone = client.new()
|
||||
|
||||
methods = _binding_methods(clone)
|
||||
assert methods[0] == TASK_STATUS_METHOD
|
||||
assert CUSTOM_METHOD in methods
|
||||
assert clone._session_kwargs.get("extensions") == {EXTENSION_ID: {"enabled": True}}
|
||||
assert clone._claim_by_model[ClaimedResult].result_type == CLAIMED_TYPE
|
||||
|
||||
|
||||
def test_result_claims_merge_with_extension_claims():
|
||||
"""Explicit result_claims merge with an advertised extension's own claims."""
|
||||
|
||||
class ExtraClaimed(Result):
|
||||
result_type: Literal["x-test/extra"]
|
||||
|
||||
async def _resolve_extra(result: ExtraClaimed, ctx: ClaimContext) -> CallToolResult:
|
||||
return CallToolResult(content=[])
|
||||
|
||||
extra_claim = ResultClaim(
|
||||
result_type="x-test/extra",
|
||||
model=ExtraClaimed,
|
||||
resolve=_resolve_extra,
|
||||
)
|
||||
|
||||
client = Client(
|
||||
FastMCP("srv"),
|
||||
extensions=[_DemoExtension()],
|
||||
result_claims={EXTENSION_ID: [extra_claim]},
|
||||
)
|
||||
|
||||
result_claims = client._session_kwargs.get("result_claims")
|
||||
assert result_claims is not None
|
||||
tags = {c.result_type for c in result_claims[EXTENSION_ID]}
|
||||
assert tags == {CLAIMED_TYPE, "x-test/extra"}
|
||||
# Both the extension claim and the explicit extra claim are resolvable.
|
||||
assert set(client._claim_by_model) == {ClaimedResult, ExtraClaimed}
|
||||
|
||||
|
||||
async def test_user_binding_clobbering_task_method_is_rejected():
|
||||
"""A user extension binding the task-status method cannot silently replace it.
|
||||
|
||||
Composition means the internal task binding always leads; a user extension
|
||||
that binds the same method collides with it, and the SDK session rejects the
|
||||
duplicate at connect time rather than letting one silently win.
|
||||
"""
|
||||
|
||||
class TaskClobberExtension(ClientExtension):
|
||||
identifier = "test.example.com/clobber"
|
||||
|
||||
def notifications(self):
|
||||
async def _handler(params: PingParams) -> None: ...
|
||||
|
||||
return (
|
||||
NotificationBinding(
|
||||
method=TASK_STATUS_METHOD,
|
||||
params_type=PingParams,
|
||||
handler=_handler,
|
||||
),
|
||||
)
|
||||
|
||||
client = Client(FastMCP("srv"), extensions=[TaskClobberExtension()])
|
||||
with pytest.raises(RuntimeError, match="duplicate notification binding"):
|
||||
async with client:
|
||||
pass
|
||||
|
||||
|
||||
async def test_both_bindings_fire_against_live_server():
|
||||
"""The internal task binding and a user extension binding both fire.
|
||||
|
||||
A ``task=True`` tool drives ``notifications/tasks/status`` (the internal
|
||||
binding) while a second tool emits a custom notification the user extension
|
||||
observes, proving the two coexist on one live connection. Pinned to
|
||||
``mode="legacy"`` because FastMCP task submission is a legacy-era feature.
|
||||
"""
|
||||
received: list[PingParams] = []
|
||||
mcp = FastMCP("compose-server")
|
||||
|
||||
@mcp.tool
|
||||
async def emit(value: int) -> int:
|
||||
ctx = get_context()
|
||||
# Emit a custom (non-core) notification straight onto the outbound
|
||||
# channel; unknown methods route to the client's notification bindings.
|
||||
await ctx.session._connection.notify(CUSTOM_METHOD, {"value": value})
|
||||
return value
|
||||
|
||||
@mcp.tool(task=True)
|
||||
async def background(value: int) -> int:
|
||||
await asyncio.sleep(0.02)
|
||||
return value * 2
|
||||
|
||||
client = Client(mcp, extensions=[_DemoExtension(received)], mode="legacy")
|
||||
|
||||
async with client:
|
||||
# The user extension binding fires on the custom notification.
|
||||
await client.call_tool("emit", {"value": 21})
|
||||
# The internal task binding fires on the task-status notification.
|
||||
task = await client.call_tool("background", {"value": 5}, task=True)
|
||||
status = await task.wait(timeout=2.0)
|
||||
# Give the custom-notification queue a moment to drain.
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
# Internal task binding fired: the task completed via a status notification.
|
||||
assert status.status == "completed"
|
||||
# User extension binding fired: it observed the custom notification.
|
||||
assert [p.value for p in received] == [21]
|
||||
|
||||
|
||||
class TestClaimedResultResolution:
|
||||
"""End-to-end resolution of a server-emitted claimed `tools/call` result."""
|
||||
|
||||
@pytest.mark.parametrize("mode", ["auto", LATEST_MODERN_VERSION])
|
||||
async def test_call_tool_mcp_resolves_claimed_result(self, mode):
|
||||
"""`call_tool_mcp` resolves a claimed result through the extension resolver.
|
||||
|
||||
The server emits a claimed shape; the client's registered extension
|
||||
parses it and its resolver finishes it into an ordinary CallToolResult.
|
||||
Both the negotiated (`auto`) and pinned modern eras admit the claim.
|
||||
"""
|
||||
client = Client(_claiming_server(), extensions=[_DemoExtension()], mode=mode)
|
||||
async with client:
|
||||
assert client.protocol_version == LATEST_MODERN_VERSION
|
||||
result = await client.call_tool_mcp("claimed_tool", {})
|
||||
|
||||
block = result.content[0]
|
||||
assert isinstance(block, TextContent)
|
||||
assert block.text == "resolved:from-server"
|
||||
|
||||
async def test_call_tool_resolves_claimed_result(self):
|
||||
"""The high-level `call_tool` also returns the resolver's CallToolResult."""
|
||||
client = Client(
|
||||
_claiming_server(),
|
||||
extensions=[_DemoExtension()],
|
||||
mode=LATEST_MODERN_VERSION,
|
||||
)
|
||||
async with client:
|
||||
parsed = await client.call_tool("claimed_tool", {})
|
||||
|
||||
block = parsed.content[0]
|
||||
assert isinstance(block, TextContent)
|
||||
assert block.text == "resolved:from-server"
|
||||
|
||||
async def test_unwired_session_call_raises_unexpected_claimed(self):
|
||||
"""Regression guard for the half-wired bug: the raw session path raises.
|
||||
|
||||
With the claim registered, calling `session.call_tool` directly (FastMCP's
|
||||
old tool path, which omitted `allow_claimed=True`) surfaces the claimed
|
||||
result as `UnexpectedClaimedResult` — the exact failure the wired
|
||||
`call_tool_mcp` path now avoids by resolving instead.
|
||||
"""
|
||||
client = Client(
|
||||
_claiming_server(),
|
||||
extensions=[_DemoExtension()],
|
||||
mode=LATEST_MODERN_VERSION,
|
||||
)
|
||||
async with client:
|
||||
with pytest.raises(UnexpectedClaimedResult):
|
||||
await client.session.call_tool("claimed_tool", {})
|
||||
|
||||
# The wired path resolves the very same claimed result.
|
||||
resolved = await client.call_tool_mcp("claimed_tool", {})
|
||||
block = resolved.content[0]
|
||||
assert isinstance(block, TextContent)
|
||||
assert block.text == "resolved:from-server"
|
||||
|
|
@ -51,7 +51,7 @@ def fastmcp_server():
|
|||
async def test_elicitation_with_no_handler(fastmcp_server):
|
||||
"""Test that elicitation works without a handler."""
|
||||
|
||||
async with Client(fastmcp_server) as client:
|
||||
async with Client(fastmcp_server, mode="legacy") as client:
|
||||
with pytest.raises(ToolError, match="Elicitation not supported"):
|
||||
await client.call_tool("ask_for_name")
|
||||
|
||||
|
|
@ -64,7 +64,7 @@ async def test_elicitation_accept_content(fastmcp_server):
|
|||
return ElicitResult(action="accept", content=response_type(name="Alice"))
|
||||
|
||||
async with Client(
|
||||
fastmcp_server, elicitation_handler=elicitation_handler
|
||||
fastmcp_server, mode="legacy", elicitation_handler=elicitation_handler
|
||||
) as client:
|
||||
result = await client.call_tool("ask_for_name")
|
||||
assert result.data == "Hello, Alice!"
|
||||
|
|
@ -77,7 +77,7 @@ async def test_elicitation_decline(fastmcp_server):
|
|||
return ElicitResult(action="decline")
|
||||
|
||||
async with Client(
|
||||
fastmcp_server, elicitation_handler=elicitation_handler
|
||||
fastmcp_server, mode="legacy", elicitation_handler=elicitation_handler
|
||||
) as client:
|
||||
result = await client.call_tool("ask_for_name")
|
||||
assert result.data == "No name provided."
|
||||
|
|
@ -103,7 +103,9 @@ async def test_elicitation_handler_parameters():
|
|||
captured_params["ctx"] = ctx
|
||||
return ElicitResult(action="accept", content={"value": 42})
|
||||
|
||||
async with Client(mcp, elicitation_handler=elicitation_handler) as client:
|
||||
async with Client(
|
||||
mcp, mode="legacy", elicitation_handler=elicitation_handler
|
||||
) as client:
|
||||
await client.call_tool("test_tool", {})
|
||||
|
||||
assert captured_params["message"] == "Test message"
|
||||
|
|
@ -136,7 +138,9 @@ async def test_elicitation_response_title_and_description_on_scalar():
|
|||
captured_schema.update(params.requested_schema)
|
||||
return ElicitResult(action="accept", content={"value": True})
|
||||
|
||||
async with Client(mcp, elicitation_handler=elicitation_handler) as client:
|
||||
async with Client(
|
||||
mcp, mode="legacy", elicitation_handler=elicitation_handler
|
||||
) as client:
|
||||
await client.call_tool("confirm_purchase", {})
|
||||
|
||||
assert captured_schema["properties"]["value"]["title"] == "Confirm purchase"
|
||||
|
|
@ -165,7 +169,9 @@ async def test_elicitation_response_title_on_dict_shorthand():
|
|||
captured_schema.update(params.requested_schema)
|
||||
return ElicitResult(action="accept", content={"value": "low"})
|
||||
|
||||
async with Client(mcp, elicitation_handler=elicitation_handler) as client:
|
||||
async with Client(
|
||||
mcp, mode="legacy", elicitation_handler=elicitation_handler
|
||||
) as client:
|
||||
await client.call_tool("pick_priority", {})
|
||||
|
||||
assert captured_schema["properties"]["value"]["title"] == "Priority level"
|
||||
|
|
@ -189,7 +195,9 @@ async def test_elicitation_response_title_on_list_shorthand():
|
|||
captured_schema.update(params.requested_schema)
|
||||
return ElicitResult(action="accept", content={"value": "red"})
|
||||
|
||||
async with Client(mcp, elicitation_handler=elicitation_handler) as client:
|
||||
async with Client(
|
||||
mcp, mode="legacy", elicitation_handler=elicitation_handler
|
||||
) as client:
|
||||
await client.call_tool("pick_color", {})
|
||||
|
||||
assert captured_schema["properties"]["value"]["title"] == "Favorite color"
|
||||
|
|
@ -214,7 +222,9 @@ async def test_elicitation_response_title_rejected_for_basemodel():
|
|||
async def elicitation_handler(message, response_type, params, ctx):
|
||||
return ElicitResult(action="accept", content={"name": "x"})
|
||||
|
||||
async with Client(mcp, elicitation_handler=elicitation_handler) as client:
|
||||
async with Client(
|
||||
mcp, mode="legacy", elicitation_handler=elicitation_handler
|
||||
) as client:
|
||||
with pytest.raises(ToolError, match="response_title"):
|
||||
await client.call_tool("ask", {})
|
||||
|
||||
|
|
@ -235,7 +245,9 @@ async def test_elicitation_response_title_rejected_for_none():
|
|||
async def elicitation_handler(message, response_type, params, ctx):
|
||||
return ElicitResult(action="accept", content={})
|
||||
|
||||
async with Client(mcp, elicitation_handler=elicitation_handler) as client:
|
||||
async with Client(
|
||||
mcp, mode="legacy", elicitation_handler=elicitation_handler
|
||||
) as client:
|
||||
with pytest.raises(ToolError, match="response_title"):
|
||||
await client.call_tool("ask", {})
|
||||
|
||||
|
|
@ -261,7 +273,9 @@ async def test_elicitation_cancel_action():
|
|||
async def elicitation_handler(message, response_type, params, ctx):
|
||||
return ElicitResult(action="cancel")
|
||||
|
||||
async with Client(mcp, elicitation_handler=elicitation_handler) as client:
|
||||
async with Client(
|
||||
mcp, mode="legacy", elicitation_handler=elicitation_handler
|
||||
) as client:
|
||||
result = await client.call_tool("ask_for_optional_info", {})
|
||||
assert result.data == "Request was canceled"
|
||||
|
||||
|
|
@ -281,7 +295,9 @@ class TestScalarResponseTypes:
|
|||
async def elicitation_handler(message, response_type, params, ctx):
|
||||
return ElicitResult(action="accept", content="Alice")
|
||||
|
||||
async with Client(mcp, elicitation_handler=elicitation_handler) as client:
|
||||
async with Client(
|
||||
mcp, mode="legacy", elicitation_handler=elicitation_handler
|
||||
) as client:
|
||||
result = await client.call_tool("my_tool", {})
|
||||
assert result.data == "Alice"
|
||||
|
||||
|
|
@ -304,7 +320,9 @@ class TestScalarResponseTypes:
|
|||
assert response_type is None
|
||||
return ElicitResult(action="accept")
|
||||
|
||||
async with Client(mcp, elicitation_handler=elicitation_handler) as client:
|
||||
async with Client(
|
||||
mcp, mode="legacy", elicitation_handler=elicitation_handler
|
||||
) as client:
|
||||
result = await client.call_tool("my_tool", {})
|
||||
assert result.data is None
|
||||
|
||||
|
|
@ -326,7 +344,9 @@ class TestScalarResponseTypes:
|
|||
):
|
||||
return ElicitResult(action="accept", content={})
|
||||
|
||||
async with Client(mcp, elicitation_handler=elicitation_handler) as client:
|
||||
async with Client(
|
||||
mcp, mode="legacy", elicitation_handler=elicitation_handler
|
||||
) as client:
|
||||
result = await client.call_tool("my_tool", {})
|
||||
assert result.data is None
|
||||
|
||||
|
|
@ -346,7 +366,9 @@ class TestScalarResponseTypes:
|
|||
async def elicitation_handler(message, response_type, params, ctx):
|
||||
return ElicitResult(action="accept", content={"value": "hello"})
|
||||
|
||||
async with Client(mcp, elicitation_handler=elicitation_handler) as client:
|
||||
async with Client(
|
||||
mcp, mode="legacy", elicitation_handler=elicitation_handler
|
||||
) as client:
|
||||
with pytest.raises(
|
||||
ToolError, match="Elicitation expected an empty response"
|
||||
):
|
||||
|
|
@ -366,7 +388,9 @@ class TestScalarResponseTypes:
|
|||
async def elicitation_handler(message, response_type, params, ctx):
|
||||
return ElicitResult(action="accept", content={"value": "hello"})
|
||||
|
||||
async with Client(mcp, elicitation_handler=elicitation_handler) as client:
|
||||
async with Client(
|
||||
mcp, mode="legacy", elicitation_handler=elicitation_handler
|
||||
) as client:
|
||||
result = await client.call_tool("my_tool", {})
|
||||
assert result.data == "hello"
|
||||
|
||||
|
|
@ -384,7 +408,9 @@ class TestScalarResponseTypes:
|
|||
async def elicitation_handler(message, response_type, params, ctx):
|
||||
return ElicitResult(action="accept", content={"value": 42})
|
||||
|
||||
async with Client(mcp, elicitation_handler=elicitation_handler) as client:
|
||||
async with Client(
|
||||
mcp, mode="legacy", elicitation_handler=elicitation_handler
|
||||
) as client:
|
||||
result = await client.call_tool("my_tool", {})
|
||||
assert result.data == 42
|
||||
|
||||
|
|
@ -402,7 +428,9 @@ class TestScalarResponseTypes:
|
|||
async def elicitation_handler(message, response_type, params, ctx):
|
||||
return ElicitResult(action="accept", content={"value": 3.14})
|
||||
|
||||
async with Client(mcp, elicitation_handler=elicitation_handler) as client:
|
||||
async with Client(
|
||||
mcp, mode="legacy", elicitation_handler=elicitation_handler
|
||||
) as client:
|
||||
result = await client.call_tool("my_tool", {})
|
||||
assert result.data == 3.14
|
||||
|
||||
|
|
@ -420,7 +448,9 @@ class TestScalarResponseTypes:
|
|||
async def elicitation_handler(message, response_type, params, ctx):
|
||||
return ElicitResult(action="accept", content={"value": True})
|
||||
|
||||
async with Client(mcp, elicitation_handler=elicitation_handler) as client:
|
||||
async with Client(
|
||||
mcp, mode="legacy", elicitation_handler=elicitation_handler
|
||||
) as client:
|
||||
result = await client.call_tool("my_tool", {})
|
||||
assert result.data is True
|
||||
|
||||
|
|
@ -440,7 +470,9 @@ class TestScalarResponseTypes:
|
|||
async def elicitation_handler(message, response_type, params, ctx):
|
||||
return ElicitResult(action="accept", content={"value": "x"})
|
||||
|
||||
async with Client(mcp, elicitation_handler=elicitation_handler) as client:
|
||||
async with Client(
|
||||
mcp, mode="legacy", elicitation_handler=elicitation_handler
|
||||
) as client:
|
||||
result = await client.call_tool("my_tool", {})
|
||||
assert result.data == "x"
|
||||
|
||||
|
|
@ -462,7 +494,9 @@ class TestScalarResponseTypes:
|
|||
async def elicitation_handler(message, response_type, params, ctx):
|
||||
return ElicitResult(action="accept", content={"value": "x"})
|
||||
|
||||
async with Client(mcp, elicitation_handler=elicitation_handler) as client:
|
||||
async with Client(
|
||||
mcp, mode="legacy", elicitation_handler=elicitation_handler
|
||||
) as client:
|
||||
result = await client.call_tool("my_tool", {})
|
||||
assert result.data == "x"
|
||||
|
||||
|
|
@ -480,7 +514,9 @@ class TestScalarResponseTypes:
|
|||
async def elicitation_handler(message, response_type, params, ctx):
|
||||
return ElicitResult(action="accept", content={"value": "x"})
|
||||
|
||||
async with Client(mcp, elicitation_handler=elicitation_handler) as client:
|
||||
async with Client(
|
||||
mcp, mode="legacy", elicitation_handler=elicitation_handler
|
||||
) as client:
|
||||
result = await client.call_tool("my_tool", {})
|
||||
assert result.data == "x"
|
||||
|
||||
|
|
@ -504,7 +540,9 @@ async def test_elicitation_handler_error():
|
|||
async def elicitation_handler(message, response_type, params, ctx):
|
||||
raise ValueError("Handler failed!")
|
||||
|
||||
async with Client(mcp, elicitation_handler=elicitation_handler) as client:
|
||||
async with Client(
|
||||
mcp, mode="legacy", elicitation_handler=elicitation_handler
|
||||
) as client:
|
||||
result = await client.call_tool("failing_elicit", {})
|
||||
assert "Error:" in result.data
|
||||
|
||||
|
|
@ -547,7 +585,9 @@ async def test_elicitation_multiple_calls():
|
|||
else:
|
||||
raise ValueError("Unexpected call")
|
||||
|
||||
async with Client(mcp, elicitation_handler=elicitation_handler) as client:
|
||||
async with Client(
|
||||
mcp, mode="legacy", elicitation_handler=elicitation_handler
|
||||
) as client:
|
||||
result = await client.call_tool("multi_step_form", {})
|
||||
assert result.data == "Hello Bob, you are 25 years old"
|
||||
assert call_count == 2
|
||||
|
|
@ -619,7 +659,9 @@ async def test_structured_response_type(
|
|||
|
||||
return ElicitResult(action="accept", content=UserInfo(name="Alice", age=30))
|
||||
|
||||
async with Client(mcp, elicitation_handler=elicitation_handler) as client:
|
||||
async with Client(
|
||||
mcp, mode="legacy", elicitation_handler=elicitation_handler
|
||||
) as client:
|
||||
result = await client.call_tool("get_user_info", {})
|
||||
assert result.data == "User: Alice, age: 30"
|
||||
|
||||
|
|
@ -666,7 +708,9 @@ async def test_all_primitive_field_types():
|
|||
),
|
||||
)
|
||||
|
||||
async with Client(mcp, elicitation_handler=elicitation_handler) as client:
|
||||
async with Client(
|
||||
mcp, mode="legacy", elicitation_handler=elicitation_handler
|
||||
) as client:
|
||||
result = await client.call_tool("get_data", {})
|
||||
|
||||
# Now all literal/enum fields should be preserved as strings
|
||||
|
|
@ -746,7 +790,9 @@ class TestPatternMatching:
|
|||
async def elicitation_handler(message, response_type, params, ctx):
|
||||
return ElicitResult(action="accept", content={"value": "Alice"})
|
||||
|
||||
async with Client(mcp, elicitation_handler=elicitation_handler) as client:
|
||||
async with Client(
|
||||
mcp, mode="legacy", elicitation_handler=elicitation_handler
|
||||
) as client:
|
||||
result = await client.call_tool("pattern_match_tool", {})
|
||||
assert result.data == "Hello Alice!"
|
||||
|
||||
|
|
@ -771,7 +817,9 @@ class TestPatternMatching:
|
|||
async def elicitation_handler(message, response_type, params, ctx):
|
||||
return ElicitResult(action="decline")
|
||||
|
||||
async with Client(mcp, elicitation_handler=elicitation_handler) as client:
|
||||
async with Client(
|
||||
mcp, mode="legacy", elicitation_handler=elicitation_handler
|
||||
) as client:
|
||||
result = await client.call_tool("pattern_match_tool", {})
|
||||
assert result.data == "You declined"
|
||||
|
||||
|
|
@ -796,6 +844,8 @@ class TestPatternMatching:
|
|||
async def elicitation_handler(message, response_type, params, ctx):
|
||||
return ElicitResult(action="cancel")
|
||||
|
||||
async with Client(mcp, elicitation_handler=elicitation_handler) as client:
|
||||
async with Client(
|
||||
mcp, mode="legacy", elicitation_handler=elicitation_handler
|
||||
) as client:
|
||||
result = await client.call_tool("pattern_match_tool", {})
|
||||
assert result.data == "Cancelled"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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})
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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", {})
|
||||
|
|
|
|||
|
|
@ -825,6 +825,11 @@ class TestAuthMiddlewareCallTool:
|
|||
|
||||
|
||||
class TestAuthMiddlewareVersionedRequests:
|
||||
# The resource/template/prompt denial cases are pinned to legacy: the
|
||||
# authorization error message is surfaced to the client on the handshake
|
||||
# era, but the modern server runner masks the raised denial as a generic
|
||||
# "Internal server error". The tool case surfaces via an isError result and
|
||||
# stays era-neutral.
|
||||
async def test_middleware_blocks_explicit_restricted_tool_version(self):
|
||||
"""AuthMiddleware should check the requested tool version."""
|
||||
mcp = make_restricted_tag_server()
|
||||
|
|
@ -878,7 +883,7 @@ class TestAuthMiddlewareVersionedRequests:
|
|||
|
||||
tok = set_token(make_token(scopes=["read"]))
|
||||
try:
|
||||
async with Client(mcp) as client:
|
||||
async with Client(mcp, mode="legacy") as client:
|
||||
with pytest.raises(Exception, match="authorization|insufficient"):
|
||||
await client.read_resource("data://info", version="1.0")
|
||||
finally:
|
||||
|
|
@ -898,7 +903,7 @@ class TestAuthMiddlewareVersionedRequests:
|
|||
|
||||
tok = set_token(make_token(scopes=["read"]))
|
||||
try:
|
||||
async with Client(mcp) as client:
|
||||
async with Client(mcp, mode="legacy") as client:
|
||||
with pytest.raises(Exception, match="authorization|insufficient"):
|
||||
await client.read_resource("data://items/123", version="1.0")
|
||||
finally:
|
||||
|
|
@ -918,7 +923,7 @@ class TestAuthMiddlewareVersionedRequests:
|
|||
|
||||
tok = set_token(make_token(scopes=["read"]))
|
||||
try:
|
||||
async with Client(mcp) as client:
|
||||
async with Client(mcp, mode="legacy") as client:
|
||||
with pytest.raises(Exception, match="authorization|insufficient"):
|
||||
await client.get_prompt("greet", version="1.0")
|
||||
finally:
|
||||
|
|
@ -942,6 +947,12 @@ class TestComponentAuthDenialMessage:
|
|||
The message must stay ambiguous ("not found or not authorized") rather than
|
||||
asserting the component does not exist (misleading) or that it exists but is
|
||||
forbidden (leaks existence to unauthorized callers).
|
||||
|
||||
The resource/prompt cases are pinned to legacy: their denial message is
|
||||
surfaced only on the handshake era, where the read/get path converts the
|
||||
error to a client-visible message; the modern server runner masks it as a
|
||||
generic "Internal server error". The tool case surfaces via an isError
|
||||
result and stays era-neutral.
|
||||
"""
|
||||
|
||||
async def test_call_tool_denied_by_component_auth(self):
|
||||
|
|
@ -972,7 +983,7 @@ class TestComponentAuthDenialMessage:
|
|||
token = make_token(scopes=["read"])
|
||||
tok = set_token(token)
|
||||
try:
|
||||
async with Client(mcp) as client:
|
||||
async with Client(mcp, mode="legacy") as client:
|
||||
with pytest.raises(Exception) as exc_info:
|
||||
await client.read_resource("data://secret")
|
||||
message = str(exc_info.value)
|
||||
|
|
@ -990,7 +1001,7 @@ class TestComponentAuthDenialMessage:
|
|||
token = make_token(scopes=["read"])
|
||||
tok = set_token(token)
|
||||
try:
|
||||
async with Client(mcp) as client:
|
||||
async with Client(mcp, mode="legacy") as client:
|
||||
with pytest.raises(Exception) as exc_info:
|
||||
await client.get_prompt("secret_prompt")
|
||||
message = str(exc_info.value)
|
||||
|
|
|
|||
|
|
@ -381,7 +381,9 @@ class TestResponseCachingMiddlewareIntegration:
|
|||
assert not hasattr(cached_resources[0], "fn")
|
||||
assert not hasattr(cached_prompts[0], "fn")
|
||||
|
||||
async with Client(mcp) as client:
|
||||
# Pinned to legacy: the tool's `execution.task_support` (SEP-1686) is
|
||||
# advertised in the handshake-era tool listing; the modern listing omits it.
|
||||
async with Client(mcp, mode="legacy") as client:
|
||||
for _ in range(2):
|
||||
tools = await client.list_tools()
|
||||
resources = await client.list_resources()
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -116,7 +116,7 @@ class TestUnroutableAndMalformed:
|
|||
recorder = HookRecorder()
|
||||
server.add_middleware(recorder)
|
||||
|
||||
async with Client(server) as client:
|
||||
async with Client(server, mode="legacy") as client:
|
||||
with pytest.raises(MCPError):
|
||||
await client.session._dispatcher.send_raw_request(
|
||||
"does/not/exist", {}, {}
|
||||
|
|
@ -133,7 +133,7 @@ class TestUnroutableAndMalformed:
|
|||
recorder = HookRecorder()
|
||||
server.add_middleware(recorder)
|
||||
|
||||
async with Client(server) as client:
|
||||
async with Client(server, mode="legacy") as client:
|
||||
with pytest.raises(MCPError):
|
||||
await client.session._dispatcher.send_raw_request(
|
||||
"tools/call", {"not_a_valid": "param"}, {}
|
||||
|
|
@ -216,7 +216,7 @@ class TestMessageModification:
|
|||
server = _adder()
|
||||
server.add_middleware(RewriteLevel())
|
||||
|
||||
async with Client(server) as client:
|
||||
async with Client(server, mode="legacy") as client:
|
||||
await client.session._dispatcher.send_raw_request(
|
||||
"logging/setLevel", {"level": "not-a-valid-level"}, {}
|
||||
)
|
||||
|
|
@ -227,7 +227,7 @@ class TestMessageModification:
|
|||
recorder = HookRecorder()
|
||||
server.add_middleware(recorder)
|
||||
|
||||
async with Client(server) as client:
|
||||
async with Client(server, mode="legacy") as client:
|
||||
await client.session._dispatcher.send_raw_request(
|
||||
"logging/setLevel", {"level": "debug"}, {}
|
||||
)
|
||||
|
|
@ -254,7 +254,7 @@ class TestMessageModification:
|
|||
server.add_middleware(recorder)
|
||||
server.add_middleware(RewriteMethod())
|
||||
|
||||
async with Client(server) as client:
|
||||
async with Client(server, mode="legacy") as client:
|
||||
await client.session._dispatcher.send_raw_request("ping", {}, {})
|
||||
|
||||
# Had the rewrite redirected dispatch, the component handler would have
|
||||
|
|
@ -284,7 +284,7 @@ class TestMessageModification:
|
|||
server.add_middleware(RepairAttempt())
|
||||
server.add_middleware(recorder)
|
||||
|
||||
async with Client(server) as client:
|
||||
async with Client(server, mode="legacy") as client:
|
||||
with pytest.raises(MCPError):
|
||||
await client.session._dispatcher.send_raw_request(
|
||||
"tools/call", {"not_a_valid": "param"}, {}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -121,7 +121,10 @@ class TestToolInjectionMiddleware:
|
|||
)
|
||||
base_server.add_middleware(middleware)
|
||||
|
||||
async with Client[FastMCPTransport](base_server) as client:
|
||||
# Pinned to legacy: a middleware-injected tool's raised exception is
|
||||
# surfaced with its message on the handshake era; the modern server
|
||||
# runner reports it as a generic "Internal server error".
|
||||
async with Client[FastMCPTransport](base_server, mode="legacy") as client:
|
||||
with pytest.raises(Exception, match="Cannot divide by zero"):
|
||||
_ = await client.call_tool(name="divide", arguments={"a": 10, "b": 0})
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
from dataclasses import dataclass
|
||||
from typing import cast
|
||||
|
||||
import pytest
|
||||
from anyio import create_task_group
|
||||
|
|
@ -21,6 +22,42 @@ from fastmcp.server.elicitation import AcceptedElicitation
|
|||
from fastmcp.server.providers.proxy import ProxyClient, _create_client_factory
|
||||
|
||||
|
||||
class TestProxyClientEraDefault:
|
||||
"""`ProxyClient` pins the handshake era independently of `Client`'s default.
|
||||
|
||||
`fastmcp.Client` defaults to `mode="auto"` (negotiate the newest mutual era),
|
||||
but a proxy backend forwards the initialize handshake and server-initiated
|
||||
push features (sampling / elicitation / roots / logging), which live only on
|
||||
the handshake era. So `ProxyClient` must default to `"legacy"` regardless of
|
||||
what `Client` defaults to — flipping the general client default must never
|
||||
change proxy behavior.
|
||||
"""
|
||||
|
||||
def test_client_default_is_auto(self):
|
||||
mcp = FastMCP("Backend")
|
||||
assert Client(mcp).mode == "auto"
|
||||
|
||||
def test_proxy_client_defaults_to_legacy(self):
|
||||
mcp = FastMCP("Backend")
|
||||
assert ProxyClient(mcp).mode == "legacy"
|
||||
|
||||
def test_proxy_client_can_opt_into_auto(self):
|
||||
"""The legacy default is an override-able floor, not a hard pin."""
|
||||
mcp = FastMCP("Backend")
|
||||
assert ProxyClient(mcp, mode="auto").mode == "auto"
|
||||
|
||||
def test_create_proxy_backend_defaults_to_legacy(self):
|
||||
"""The backend client `create_proxy` builds is legacy by default too."""
|
||||
mcp = FastMCP("Backend")
|
||||
factory = _create_client_factory(mcp)
|
||||
assert cast(Client, factory()).mode == "legacy"
|
||||
|
||||
def test_create_proxy_backend_honors_explicit_mode(self):
|
||||
mcp = FastMCP("Backend")
|
||||
factory = _create_client_factory(mcp, mode="auto")
|
||||
assert cast(Client, factory()).mode == "auto"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fastmcp_server():
|
||||
mcp = FastMCP("TestServer")
|
||||
|
|
@ -97,7 +134,7 @@ class TestProxyClient:
|
|||
"""
|
||||
Test that the proxy client correctly forwards the `echo` tool meta.
|
||||
"""
|
||||
async with Client(proxy_server) as client:
|
||||
async with Client(proxy_server, mode="legacy") as client:
|
||||
tools = await client.list_tools()
|
||||
echo_tool = next(t for t in tools if t.name == "echo")
|
||||
assert echo_tool.meta == {"fastmcp": {"tags": ["echo"]}}
|
||||
|
|
@ -106,7 +143,7 @@ class TestProxyClient:
|
|||
"""
|
||||
Test that the proxy client correctly forwards an error response.
|
||||
"""
|
||||
async with Client(proxy_server) as client:
|
||||
async with Client(proxy_server, mode="legacy") as client:
|
||||
with pytest.raises(ToolError, match="Elicitation not supported"):
|
||||
await client.call_tool("elicit", {})
|
||||
|
||||
|
|
@ -121,7 +158,7 @@ class TestProxyClient:
|
|||
roots_handler_called = True
|
||||
return []
|
||||
|
||||
async with Client(proxy_server, roots=roots_handler) as client:
|
||||
async with Client(proxy_server, mode="legacy", roots=roots_handler) as client:
|
||||
await client.call_tool("list_roots", {})
|
||||
|
||||
assert roots_handler_called
|
||||
|
|
@ -130,7 +167,9 @@ class TestProxyClient:
|
|||
"""
|
||||
Test that the proxy client correctly forwards the `list_roots` response.
|
||||
"""
|
||||
async with Client(proxy_server, roots=["file://x/y/z"]) as client:
|
||||
async with Client(
|
||||
proxy_server, mode="legacy", roots=["file://x/y/z"]
|
||||
) as client:
|
||||
result = await client.call_tool("list_roots", {})
|
||||
assert result.data == ["file://x/y/z"]
|
||||
|
||||
|
|
@ -161,7 +200,9 @@ class TestProxyClient:
|
|||
)
|
||||
return ""
|
||||
|
||||
async with Client(proxy_server, sampling_handler=sampling_handler) as client:
|
||||
async with Client(
|
||||
proxy_server, mode="legacy", sampling_handler=sampling_handler
|
||||
) as client:
|
||||
await client.call_tool("sampling", {})
|
||||
|
||||
assert sampling_handler_called
|
||||
|
|
@ -171,7 +212,7 @@ class TestProxyClient:
|
|||
Test that the proxy client correctly forwards the `sampling` response.
|
||||
"""
|
||||
async with Client(
|
||||
proxy_server, sampling_handler=lambda *args: "I love FastMCP"
|
||||
proxy_server, mode="legacy", sampling_handler=lambda *args: "I love FastMCP"
|
||||
) as client:
|
||||
result = await client.call_tool("sampling", {})
|
||||
assert result.data == "I love FastMCP"
|
||||
|
|
@ -199,7 +240,7 @@ class TestProxyClient:
|
|||
return ElicitResult(action="accept", content=response_type(name="Alice"))
|
||||
|
||||
async with Client(
|
||||
proxy_server, elicitation_handler=elicitation_handler
|
||||
proxy_server, mode="legacy", elicitation_handler=elicitation_handler
|
||||
) as client:
|
||||
await client.call_tool("elicit", {})
|
||||
|
||||
|
|
@ -217,6 +258,7 @@ class TestProxyClient:
|
|||
|
||||
async with Client(
|
||||
proxy_server,
|
||||
mode="legacy",
|
||||
elicitation_handler=elicitation_handler,
|
||||
) as client:
|
||||
result = await client.call_tool("elicit", {})
|
||||
|
|
@ -233,7 +275,7 @@ class TestProxyClient:
|
|||
return ElicitResult(action="decline")
|
||||
|
||||
async with Client(
|
||||
proxy_server, elicitation_handler=elicitation_handler
|
||||
proxy_server, mode="legacy", elicitation_handler=elicitation_handler
|
||||
) as client:
|
||||
result = await client.call_tool("elicit", {})
|
||||
assert result.data == "No name provided."
|
||||
|
|
@ -251,7 +293,9 @@ class TestProxyClient:
|
|||
assert message.level == "info"
|
||||
assert message.logger == "test"
|
||||
|
||||
async with Client(proxy_server, log_handler=log_handler) as client:
|
||||
async with Client(
|
||||
proxy_server, mode="legacy", log_handler=log_handler
|
||||
) as client:
|
||||
await client.call_tool(
|
||||
"log", {"message": "Hello, world!", "level": "info", "logger": "test"}
|
||||
)
|
||||
|
|
@ -277,7 +321,9 @@ class TestProxyClient:
|
|||
dict(progress=progress, total=total, message=message)
|
||||
)
|
||||
|
||||
async with Client(proxy_server, progress_handler=progress_handler) as client:
|
||||
async with Client(
|
||||
proxy_server, mode="legacy", progress_handler=progress_handler
|
||||
) as client:
|
||||
await client.call_tool("report_progress", {})
|
||||
|
||||
assert PROGRESS_MESSAGES == EXPECTED_PROGRESS_MESSAGES
|
||||
|
|
@ -293,8 +339,8 @@ class TestProxyClient:
|
|||
results["logger_b"] = message
|
||||
|
||||
async with (
|
||||
Client(proxy_server, log_handler=log_handler_a) as client_a,
|
||||
Client(proxy_server, log_handler=log_handler_b) as client_b,
|
||||
Client(proxy_server, mode="legacy", log_handler=log_handler_a) as client_a,
|
||||
Client(proxy_server, mode="legacy", log_handler=log_handler_b) as client_b,
|
||||
):
|
||||
async with create_task_group() as tg:
|
||||
tg.start_soon(
|
||||
|
|
@ -336,8 +382,12 @@ class TestProxyClient:
|
|||
results[name] = result.data
|
||||
|
||||
async with (
|
||||
Client(proxy_server, elicitation_handler=elicitation_handler_a) as client_a,
|
||||
Client(proxy_server, elicitation_handler=elicitation_handler_b) as client_b,
|
||||
Client(
|
||||
proxy_server, mode="legacy", elicitation_handler=elicitation_handler_a
|
||||
) as client_a,
|
||||
Client(
|
||||
proxy_server, mode="legacy", elicitation_handler=elicitation_handler_b
|
||||
) as client_b,
|
||||
):
|
||||
async with create_task_group() as tg:
|
||||
tg.start_soon(
|
||||
|
|
@ -396,7 +446,7 @@ class TestProxyClient:
|
|||
return {"content": "Test content", "acknowledge": True}
|
||||
|
||||
async with Client(
|
||||
proxy_server, elicitation_handler=elicitation_handler
|
||||
proxy_server, mode="legacy", elicitation_handler=elicitation_handler
|
||||
) as client:
|
||||
result = await client.call_tool("elicit_with_defaults", {})
|
||||
assert result.data == "Content: Test content, Acknowledge: True"
|
||||
|
|
@ -406,7 +456,7 @@ class TestProxyClient:
|
|||
from fastmcp.server.providers.proxy import FastMCPProxy
|
||||
|
||||
# Create a disconnected client (should use fresh sessions per request)
|
||||
base_client = Client(fastmcp_server)
|
||||
base_client = Client(fastmcp_server, mode="legacy")
|
||||
|
||||
# Test both create_proxy convenience function and direct client_factory usage
|
||||
proxy_via_create_proxy = create_proxy(base_client)
|
||||
|
|
@ -488,7 +538,9 @@ class TestProxyServerInitiatedForwardingNonTool:
|
|||
roots_handler_called = True
|
||||
return ["file://from/client"]
|
||||
|
||||
async with Client(roots_proxy_server, roots=roots_handler) as client:
|
||||
async with Client(
|
||||
roots_proxy_server, mode="legacy", roots=roots_handler
|
||||
) as client:
|
||||
result = await client.read_resource("data://roots")
|
||||
|
||||
assert roots_handler_called
|
||||
|
|
@ -504,7 +556,9 @@ class TestProxyServerInitiatedForwardingNonTool:
|
|||
roots_handler_called = True
|
||||
return ["file://from/client"]
|
||||
|
||||
async with Client(roots_proxy_server, roots=roots_handler) as client:
|
||||
async with Client(
|
||||
roots_proxy_server, mode="legacy", roots=roots_handler
|
||||
) as client:
|
||||
result = await client.read_resource("data://roots/abc")
|
||||
|
||||
assert roots_handler_called
|
||||
|
|
@ -520,7 +574,9 @@ class TestProxyServerInitiatedForwardingNonTool:
|
|||
roots_handler_called = True
|
||||
return ["file://from/client"]
|
||||
|
||||
async with Client(roots_proxy_server, roots=roots_handler) as client:
|
||||
async with Client(
|
||||
roots_proxy_server, mode="legacy", roots=roots_handler
|
||||
) as client:
|
||||
result = await client.get_prompt("roots_prompt")
|
||||
|
||||
assert roots_handler_called
|
||||
|
|
|
|||
|
|
@ -180,7 +180,7 @@ async def test_create_proxy_with_client(fastmcp_server):
|
|||
async def test_create_proxy_with_server(fastmcp_server):
|
||||
"""create_proxy should accept a FastMCP instance."""
|
||||
proxy = create_proxy(fastmcp_server)
|
||||
async with Client(proxy) as client:
|
||||
async with Client(proxy, mode="legacy") as client:
|
||||
result = await client.call_tool("greet", {"name": "Test"})
|
||||
assert result.data == "Hello, Test!"
|
||||
|
||||
|
|
@ -188,7 +188,7 @@ async def test_create_proxy_with_server(fastmcp_server):
|
|||
async def test_create_proxy_with_transport(fastmcp_server):
|
||||
"""create_proxy should accept a ClientTransport."""
|
||||
proxy = create_proxy(FastMCPTransport(fastmcp_server))
|
||||
async with Client(proxy) as client:
|
||||
async with Client(proxy, mode="legacy") as client:
|
||||
result = await client.call_tool("greet", {"name": "Test"})
|
||||
assert result.data == "Hello, Test!"
|
||||
|
||||
|
|
@ -198,7 +198,7 @@ async def test_proxy_forwards_upstream_instructions():
|
|||
upstream = FastMCP(name="upstream", instructions="USE_THIS_MARKER_123")
|
||||
proxy = create_proxy(upstream, name="proxy")
|
||||
|
||||
async with Client(proxy) as client:
|
||||
async with Client(proxy, mode="legacy") as client:
|
||||
assert client.initialize_result is not None
|
||||
assert client.initialize_result.instructions == "USE_THIS_MARKER_123"
|
||||
|
||||
|
|
@ -208,7 +208,7 @@ async def test_proxy_own_instructions_take_precedence():
|
|||
upstream = FastMCP(name="upstream", instructions="upstream instructions")
|
||||
proxy = create_proxy(upstream, name="proxy", instructions="proxy instructions")
|
||||
|
||||
async with Client(proxy) as client:
|
||||
async with Client(proxy, mode="legacy") as client:
|
||||
assert client.initialize_result is not None
|
||||
assert client.initialize_result.instructions == "proxy instructions"
|
||||
|
||||
|
|
@ -218,7 +218,7 @@ async def test_proxy_instructions_none_when_upstream_has_none():
|
|||
upstream = FastMCP(name="upstream")
|
||||
proxy = create_proxy(upstream, name="proxy")
|
||||
|
||||
async with Client(proxy) as client:
|
||||
async with Client(proxy, mode="legacy") as client:
|
||||
assert client.initialize_result is not None
|
||||
assert client.initialize_result.instructions is None
|
||||
|
||||
|
|
@ -252,7 +252,7 @@ async def test_proxy_with_async_client_factory():
|
|||
async def test_proxy_ping_forwards_to_remote_server(fastmcp_server):
|
||||
proxy = create_proxy(fastmcp_server)
|
||||
|
||||
async with Client(proxy) as client:
|
||||
async with Client(proxy, mode="legacy") as client:
|
||||
assert await client.ping() is True
|
||||
|
||||
|
||||
|
|
@ -264,7 +264,7 @@ async def test_proxy_ping_surfaces_wrong_remote_path():
|
|||
# SDK v2 surfaces a wrong remote path as an HTTP "Not Found" rather than
|
||||
# the v1 "Session terminated" message.
|
||||
with pytest.raises(MCPError, match="Not Found"):
|
||||
async with Client(proxy):
|
||||
async with Client(proxy, mode="legacy"):
|
||||
pass
|
||||
|
||||
|
||||
|
|
@ -276,7 +276,7 @@ async def test_proxy_initialize_forwards_remote_connection_error():
|
|||
)
|
||||
|
||||
with pytest.raises(MCPError, match="Client failed to connect"):
|
||||
async with Client(proxy):
|
||||
async with Client(proxy, mode="legacy"):
|
||||
pass
|
||||
|
||||
|
||||
|
|
@ -299,7 +299,7 @@ async def test_proxy_list_tools_client_surfaces_remote_connection_error():
|
|||
)
|
||||
|
||||
with pytest.raises(MCPError, match="Client failed to connect"):
|
||||
async with Client(proxy) as client:
|
||||
async with Client(proxy, mode="legacy") as client:
|
||||
await client.list_tools()
|
||||
|
||||
|
||||
|
|
@ -356,7 +356,7 @@ class TestTools:
|
|||
)
|
||||
|
||||
proxy = create_proxy(server)
|
||||
async with Client(proxy) as client:
|
||||
async with Client(proxy, mode="legacy") as client:
|
||||
result = await client.call_tool("add_transformed", {"a": 1, "b": 2})
|
||||
assert result.data == 3
|
||||
|
||||
|
|
@ -366,31 +366,31 @@ class TestTools:
|
|||
assert tool.description is None
|
||||
|
||||
async def test_list_tools_same_as_original(self, fastmcp_server, proxy_server):
|
||||
async with Client(fastmcp_server) as original_client:
|
||||
async with Client(fastmcp_server, mode="legacy") as original_client:
|
||||
original = await original_client.list_tools()
|
||||
async with Client(proxy_server) as proxy_client:
|
||||
async with Client(proxy_server, mode="legacy") as proxy_client:
|
||||
proxied = await proxy_client.list_tools()
|
||||
assert proxied == original
|
||||
|
||||
async def test_call_tool_result_same_as_original(
|
||||
self, fastmcp_server: FastMCP, proxy_server: FastMCPProxy
|
||||
):
|
||||
async with Client(fastmcp_server) as original_client:
|
||||
async with Client(fastmcp_server, mode="legacy") as original_client:
|
||||
result = await original_client.call_tool("greet", {"name": "Alice"})
|
||||
async with Client(proxy_server) as proxy_client:
|
||||
async with Client(proxy_server, mode="legacy") as proxy_client:
|
||||
proxy_result = await proxy_client.call_tool("greet", {"name": "Alice"})
|
||||
|
||||
assert result.content == proxy_result.content
|
||||
assert result.data == proxy_result.data
|
||||
|
||||
async def test_call_tool_calls_tool(self, proxy_server):
|
||||
async with Client(proxy_server) as client:
|
||||
async with Client(proxy_server, mode="legacy") as client:
|
||||
proxy_result = await client.call_tool("add", {"a": 1, "b": 2})
|
||||
assert proxy_result.data == 3
|
||||
|
||||
async def test_error_tool_raises_error(self, proxy_server):
|
||||
with pytest.raises(ToolError, match="This is a test error"):
|
||||
async with Client(proxy_server) as client:
|
||||
async with Client(proxy_server, mode="legacy") as client:
|
||||
await client.call_tool("error_tool", {})
|
||||
|
||||
async def test_error_tool_with_image_content(self, proxy_server):
|
||||
|
|
@ -407,7 +407,7 @@ class TestTools:
|
|||
Client, "call_tool_mcp", new_callable=AsyncMock, return_value=error_result
|
||||
):
|
||||
with pytest.raises(ToolError):
|
||||
async with Client(proxy_server) as client:
|
||||
async with Client(proxy_server, mode="legacy") as client:
|
||||
await client.call_tool("error_tool", {})
|
||||
|
||||
async def test_error_tool_with_empty_content(self, proxy_server):
|
||||
|
|
@ -420,7 +420,7 @@ class TestTools:
|
|||
Client, "call_tool_mcp", new_callable=AsyncMock, return_value=error_result
|
||||
):
|
||||
with pytest.raises(ToolError):
|
||||
async with Client(proxy_server) as client:
|
||||
async with Client(proxy_server, mode="legacy") as client:
|
||||
await client.call_tool("error_tool", {})
|
||||
|
||||
async def test_error_tool_passthrough_preserves_content(self, proxy_server):
|
||||
|
|
@ -437,7 +437,7 @@ class TestTools:
|
|||
with patch.object(
|
||||
Client, "call_tool_mcp", new_callable=AsyncMock, return_value=error_result
|
||||
):
|
||||
async with Client(proxy_server) as client:
|
||||
async with Client(proxy_server, mode="legacy") as client:
|
||||
result = await client.call_tool("error_tool", {}, raise_on_error=False)
|
||||
|
||||
assert result.is_error is True
|
||||
|
|
@ -456,7 +456,7 @@ class TestTools:
|
|||
meta={"custom_key": "custom_value", "processed": True},
|
||||
)
|
||||
|
||||
async with Client(proxy_server) as client:
|
||||
async with Client(proxy_server, mode="legacy") as client:
|
||||
result = await client.call_tool("tool_with_meta", {"value": "test"})
|
||||
|
||||
assert isinstance(result.content[0], TextContent)
|
||||
|
|
@ -472,7 +472,7 @@ class TestTools:
|
|||
def greet(name: str, extra: str = "extra") -> str:
|
||||
return f"Overwritten, {name}! {extra}"
|
||||
|
||||
async with Client(proxy_server) as client:
|
||||
async with Client(proxy_server, mode="legacy") as client:
|
||||
result = await client.call_tool("greet", {"name": "Marvin", "extra": "abc"})
|
||||
assert result.data == "Overwritten, Marvin! abc"
|
||||
|
||||
|
|
@ -485,7 +485,7 @@ class TestTools:
|
|||
def greet(name: str, extra: str = "extra") -> str:
|
||||
return f"Overwritten, {name}! {extra}"
|
||||
|
||||
async with Client(proxy_server) as client:
|
||||
async with Client(proxy_server, mode="legacy") as client:
|
||||
tools = await client.list_tools()
|
||||
greet_tool = next(t for t in tools if t.name == "greet")
|
||||
assert "extra" in greet_tool.input_schema["properties"]
|
||||
|
|
@ -508,27 +508,27 @@ class TestResources:
|
|||
assert wave_resource.icons == [Icon(src="https://example.com/wave-icon.png")]
|
||||
|
||||
async def test_list_resources_same_as_original(self, fastmcp_server, proxy_server):
|
||||
async with Client(fastmcp_server) as original_client:
|
||||
async with Client(fastmcp_server, mode="legacy") as original_client:
|
||||
original = await original_client.list_resources()
|
||||
async with Client(proxy_server) as proxy_client:
|
||||
async with Client(proxy_server, mode="legacy") as proxy_client:
|
||||
proxied = await proxy_client.list_resources()
|
||||
assert proxied == original
|
||||
|
||||
async def test_read_resource(self, proxy_server: FastMCPProxy):
|
||||
async with Client(proxy_server) as client:
|
||||
async with Client(proxy_server, mode="legacy") as client:
|
||||
result = await client.read_resource("resource://wave")
|
||||
assert isinstance(result[0], TextResourceContents)
|
||||
assert result[0].text == "👋"
|
||||
|
||||
async def test_read_resource_same_as_original(self, fastmcp_server, proxy_server):
|
||||
async with Client(fastmcp_server) as client:
|
||||
async with Client(fastmcp_server, mode="legacy") as client:
|
||||
result = await client.read_resource("resource://wave")
|
||||
async with Client(proxy_server) as client:
|
||||
async with Client(proxy_server, mode="legacy") as client:
|
||||
proxy_result = await client.read_resource("resource://wave")
|
||||
assert proxy_result == result
|
||||
|
||||
async def test_read_json_resource(self, proxy_server: FastMCPProxy):
|
||||
async with Client(proxy_server) as client:
|
||||
async with Client(proxy_server, mode="legacy") as client:
|
||||
result = await client.read_resource("data://users")
|
||||
assert len(result) == 1
|
||||
assert isinstance(result[0], TextResourceContents)
|
||||
|
|
@ -541,11 +541,11 @@ class TestResources:
|
|||
):
|
||||
"""Test that proxy correctly returns all resource contents, not just the first one."""
|
||||
# Read from original server
|
||||
async with Client(fastmcp_server) as client:
|
||||
async with Client(fastmcp_server, mode="legacy") as client:
|
||||
original_result = await client.read_resource("data://multi")
|
||||
|
||||
# Read from proxy server
|
||||
async with Client(proxy_server) as client:
|
||||
async with Client(proxy_server, mode="legacy") as client:
|
||||
proxy_result = await client.read_resource("data://multi")
|
||||
|
||||
# Both should return the same number of contents
|
||||
|
|
@ -574,7 +574,7 @@ class TestResources:
|
|||
with pytest.raises(
|
||||
MCPError, match="Resource not found: 'resource://nonexistent'"
|
||||
):
|
||||
async with Client(proxy_server) as client:
|
||||
async with Client(proxy_server, mode="legacy") as client:
|
||||
await client.read_resource("resource://nonexistent")
|
||||
|
||||
async def test_proxy_can_overwrite_proxied_resource(self, proxy_server):
|
||||
|
|
@ -586,7 +586,7 @@ class TestResources:
|
|||
def overwritten_wave() -> str:
|
||||
return "Overwritten wave! 🌊"
|
||||
|
||||
async with Client(proxy_server) as client:
|
||||
async with Client(proxy_server, mode="legacy") as client:
|
||||
result = await client.read_resource("resource://wave")
|
||||
assert isinstance(result[0], TextResourceContents)
|
||||
assert result[0].text == "Overwritten wave! 🌊"
|
||||
|
|
@ -600,7 +600,7 @@ class TestResources:
|
|||
def overwritten_wave() -> str:
|
||||
return "Overwritten wave! 🌊"
|
||||
|
||||
async with Client(proxy_server) as client:
|
||||
async with Client(proxy_server, mode="legacy") as client:
|
||||
resources = await client.list_resources()
|
||||
wave_resource = next(
|
||||
r for r in resources if str(r.uri) == "resource://wave"
|
||||
|
|
@ -627,15 +627,15 @@ class TestResourceTemplates:
|
|||
async def test_list_resource_templates_same_as_original(
|
||||
self, fastmcp_server, proxy_server
|
||||
):
|
||||
async with Client(fastmcp_server) as original_client:
|
||||
async with Client(fastmcp_server, mode="legacy") as original_client:
|
||||
result = await original_client.list_resource_templates()
|
||||
async with Client(proxy_server) as proxy_client:
|
||||
async with Client(proxy_server, mode="legacy") as proxy_client:
|
||||
proxy_result = await proxy_client.list_resource_templates()
|
||||
assert proxy_result == result
|
||||
|
||||
@pytest.mark.parametrize("id", [1, 2, 3])
|
||||
async def test_read_resource_template(self, proxy_server: FastMCPProxy, id: int):
|
||||
async with Client(proxy_server) as client:
|
||||
async with Client(proxy_server, mode="legacy") as client:
|
||||
result = await client.read_resource(f"data://user/{id}")
|
||||
assert isinstance(result[0], TextResourceContents)
|
||||
assert json.loads(result[0].text) == USERS[id - 1]
|
||||
|
|
@ -643,9 +643,9 @@ class TestResourceTemplates:
|
|||
async def test_read_resource_template_same_as_original(
|
||||
self, fastmcp_server, proxy_server
|
||||
):
|
||||
async with Client(fastmcp_server) as client:
|
||||
async with Client(fastmcp_server, mode="legacy") as client:
|
||||
result = await client.read_resource("data://user/1")
|
||||
async with Client(proxy_server) as client:
|
||||
async with Client(proxy_server, mode="legacy") as client:
|
||||
proxy_result = await client.read_resource("data://user/1")
|
||||
assert proxy_result == result
|
||||
|
||||
|
|
@ -654,11 +654,11 @@ class TestResourceTemplates:
|
|||
):
|
||||
"""Test that proxy template correctly returns all resource contents."""
|
||||
# Read from original server
|
||||
async with Client(fastmcp_server) as client:
|
||||
async with Client(fastmcp_server, mode="legacy") as client:
|
||||
original_result = await client.read_resource("data://multi/test123")
|
||||
|
||||
# Read from proxy server
|
||||
async with Client(proxy_server) as client:
|
||||
async with Client(proxy_server, mode="legacy") as client:
|
||||
proxy_result = await client.read_resource("data://multi/test123")
|
||||
|
||||
# Both should return the same number of contents
|
||||
|
|
@ -696,7 +696,7 @@ class TestResourceTemplates:
|
|||
}
|
||||
)
|
||||
|
||||
async with Client(proxy_server) as client:
|
||||
async with Client(proxy_server, mode="legacy") as client:
|
||||
result = await client.read_resource("data://user/1")
|
||||
assert isinstance(result[0], TextResourceContents)
|
||||
user_data = json.loads(result[0].text)
|
||||
|
|
@ -712,7 +712,7 @@ class TestResourceTemplates:
|
|||
def overwritten_get_user(user_id: str) -> dict[str, Any]:
|
||||
return {"id": user_id, "name": "Overwritten User", "active": True}
|
||||
|
||||
async with Client(proxy_server) as client:
|
||||
async with Client(proxy_server, mode="legacy") as client:
|
||||
templates = await client.list_resource_templates()
|
||||
user_template = next(
|
||||
t for t in templates if t.uri_template == "data://user/{user_id}"
|
||||
|
|
@ -730,8 +730,8 @@ class TestResourceTemplateQueryParams:
|
|||
def get_data(id: str, format: str = "json") -> str:
|
||||
return f"id={id} format={format}"
|
||||
|
||||
proxy = create_proxy(Client(remote))
|
||||
async with Client(proxy) as client:
|
||||
proxy = create_proxy(Client(remote, mode="legacy"))
|
||||
async with Client(proxy, mode="legacy") as client:
|
||||
result = await client.read_resource("data://123?format=xml")
|
||||
assert isinstance(result[0], TextResourceContents)
|
||||
assert result[0].text == "id=123 format=xml"
|
||||
|
|
@ -743,8 +743,8 @@ class TestResourceTemplateQueryParams:
|
|||
def get_data(id: str, format: str = "json") -> str:
|
||||
return f"id={id} format={format}"
|
||||
|
||||
proxy = create_proxy(Client(remote))
|
||||
async with Client(proxy) as client:
|
||||
proxy = create_proxy(Client(remote, mode="legacy"))
|
||||
async with Client(proxy, mode="legacy") as client:
|
||||
result = await client.read_resource("data://123")
|
||||
assert isinstance(result[0], TextResourceContents)
|
||||
assert result[0].text == "id=123 format=json"
|
||||
|
|
@ -756,8 +756,8 @@ class TestResourceTemplateQueryParams:
|
|||
def get_data(id: str, limit: int = 10, offset: int = 0) -> str:
|
||||
return f"id={id} limit={limit} offset={offset}"
|
||||
|
||||
proxy = create_proxy(Client(remote))
|
||||
async with Client(proxy) as client:
|
||||
proxy = create_proxy(Client(remote, mode="legacy"))
|
||||
async with Client(proxy, mode="legacy") as client:
|
||||
result = await client.read_resource("data://abc?limit=5&offset=20")
|
||||
assert isinstance(result[0], TextResourceContents)
|
||||
assert result[0].text == "id=abc limit=5 offset=20"
|
||||
|
|
@ -769,8 +769,8 @@ class TestResourceTemplateQueryParams:
|
|||
def get_data(id: str) -> str:
|
||||
return f"id={id}"
|
||||
|
||||
proxy = create_proxy(Client(remote))
|
||||
async with Client(proxy) as client:
|
||||
proxy = create_proxy(Client(remote, mode="legacy"))
|
||||
async with Client(proxy, mode="legacy") as client:
|
||||
result = await client.read_resource("data://a%2Fb")
|
||||
assert isinstance(result[0], TextResourceContents)
|
||||
assert result[0].text == "id=a/b"
|
||||
|
|
@ -782,8 +782,8 @@ class TestResourceTemplateQueryParams:
|
|||
def get_data(id: str, api_version: str = "v1") -> str:
|
||||
return f"id={id} api_version={api_version}"
|
||||
|
||||
proxy = create_proxy(Client(remote))
|
||||
async with Client(proxy) as client:
|
||||
proxy = create_proxy(Client(remote, mode="legacy"))
|
||||
async with Client(proxy, mode="legacy") as client:
|
||||
result = await client.read_resource("data://123?api-version=v2")
|
||||
assert isinstance(result[0], TextResourceContents)
|
||||
assert result[0].text == "id=123 api_version=v2"
|
||||
|
|
@ -803,8 +803,8 @@ class TestResourceTemplateQueryParams:
|
|||
def get_data(id: str, api_version: str = "v1") -> str:
|
||||
return f"id={id} api_version={api_version}"
|
||||
|
||||
proxy = create_proxy(Client(remote))
|
||||
async with Client(proxy) as client:
|
||||
proxy = create_proxy(Client(remote, mode="legacy"))
|
||||
async with Client(proxy, mode="legacy") as client:
|
||||
result = await client.read_resource("data://123?api-version=a%2Fb")
|
||||
assert isinstance(result[0], TextResourceContents)
|
||||
assert result[0].text == "id=123 api_version=a/b"
|
||||
|
|
@ -825,23 +825,23 @@ class TestPrompts:
|
|||
]
|
||||
|
||||
async def test_list_prompts_same_as_original(self, fastmcp_server, proxy_server):
|
||||
async with Client(fastmcp_server) as client:
|
||||
async with Client(fastmcp_server, mode="legacy") as client:
|
||||
result = await client.list_prompts()
|
||||
async with Client(proxy_server) as client:
|
||||
async with Client(proxy_server, mode="legacy") as client:
|
||||
proxy_result = await client.list_prompts()
|
||||
assert proxy_result == result
|
||||
|
||||
async def test_render_prompt_same_as_original(
|
||||
self, fastmcp_server: FastMCP, proxy_server: FastMCPProxy
|
||||
):
|
||||
async with Client(fastmcp_server) as client:
|
||||
async with Client(fastmcp_server, mode="legacy") as client:
|
||||
result = await client.get_prompt("welcome", {"name": "Alice"})
|
||||
async with Client(proxy_server) as client:
|
||||
async with Client(proxy_server, mode="legacy") as client:
|
||||
proxy_result = await client.get_prompt("welcome", {"name": "Alice"})
|
||||
assert proxy_result == result
|
||||
|
||||
async def test_render_prompt_calls_prompt(self, proxy_server):
|
||||
async with Client(proxy_server) as client:
|
||||
async with Client(proxy_server, mode="legacy") as client:
|
||||
result = await client.get_prompt("welcome", {"name": "Alice"})
|
||||
assert result.messages[0].role == "user"
|
||||
assert isinstance(result.messages[0].content, TextContent)
|
||||
|
|
@ -856,7 +856,7 @@ class TestPrompts:
|
|||
def welcome(name: str, extra: str = "friend") -> str:
|
||||
return f"Overwritten welcome, {name}! You are my {extra}."
|
||||
|
||||
async with Client(proxy_server) as client:
|
||||
async with Client(proxy_server, mode="legacy") as client:
|
||||
result = await client.get_prompt(
|
||||
"welcome", {"name": "Alice", "extra": "colleague"}
|
||||
)
|
||||
|
|
@ -876,7 +876,7 @@ class TestPrompts:
|
|||
def welcome(name: str, extra: str = "friend") -> str:
|
||||
return f"Overwritten welcome, {name}! You are my {extra}."
|
||||
|
||||
async with Client(proxy_server) as client:
|
||||
async with Client(proxy_server, mode="legacy") as client:
|
||||
prompts = await client.list_prompts()
|
||||
welcome_prompt = next(p for p in prompts if p.name == "welcome")
|
||||
# Check that the overwritten prompt has the additional 'extra' parameter
|
||||
|
|
@ -887,9 +887,9 @@ class TestPrompts:
|
|||
self, fastmcp_server: FastMCP, proxy_server: FastMCPProxy
|
||||
):
|
||||
"""Test that ProxyPrompt preserves ImageContent without lossy conversion."""
|
||||
async with Client(fastmcp_server) as client:
|
||||
async with Client(fastmcp_server, mode="legacy") as client:
|
||||
result = await client.get_prompt("image_prompt")
|
||||
async with Client(proxy_server) as client:
|
||||
async with Client(proxy_server, mode="legacy") as client:
|
||||
proxy_result = await client.get_prompt("image_prompt")
|
||||
|
||||
# The proxy result should match the original exactly
|
||||
|
|
@ -1188,7 +1188,10 @@ class TestProxyOutputSchemaEnforcement:
|
|||
|
||||
async def _call_without_validating(self, server: FastMCP, tool: str):
|
||||
"""Call through a client that does not enforce the schema itself."""
|
||||
client = Client(server)
|
||||
# The proxy backend here is a legacy-era ProxyClient, so pin the end
|
||||
# client to the handshake era too; the modern-end-client-through-proxy
|
||||
# path is the separate proxy era-mirroring workstream.
|
||||
client = Client(server, mode="legacy")
|
||||
client._transport_options = TransportOptions(
|
||||
session_class=_ForwardingClientSession
|
||||
)
|
||||
|
|
@ -1230,7 +1233,8 @@ class TestProxyOutputSchemaEnforcement:
|
|||
ProxyProvider(lambda: ProxyClient(backend_violating_its_schema))
|
||||
)
|
||||
|
||||
async with Client(proxy) as client:
|
||||
# Legacy-era ProxyClient backend: pin the end client to match (see above).
|
||||
async with Client(proxy, mode="legacy") as client:
|
||||
with pytest.raises(RuntimeError, match="Invalid structured content"):
|
||||
await client.call_tool_mcp("undeclared_status", {})
|
||||
|
||||
|
|
@ -1275,7 +1279,8 @@ class TestProxyOutputSchemaEnforcement:
|
|||
proxy = FastMCP("Proxy")
|
||||
proxy.add_provider(ProxyProvider(lambda: ProxyClient(backend)))
|
||||
|
||||
async with Client(proxy) as client:
|
||||
# Legacy-era ProxyClient backend: pin the end client to match (see above).
|
||||
async with Client(proxy, mode="legacy") as client:
|
||||
await client.call_tool("echo", {"n": 1})
|
||||
lists_after_first = counts["list"]
|
||||
|
||||
|
|
@ -1349,8 +1354,14 @@ class TestProxyForwardingAppliesToEveryBackendClient:
|
|||
|
||||
return mcp
|
||||
|
||||
async def _forwarded(self, server: FastMCP, tool: str = "status"):
|
||||
client = Client(server)
|
||||
async def _forwarded(
|
||||
self, server: FastMCP, tool: str = "status", mode: str = "auto"
|
||||
):
|
||||
# `mode` follows the proxy backend's era: a modern-capable backend (plain
|
||||
# Client / single-server config) lets the end client stay on the default
|
||||
# auto era, while a legacy-only backend (multi-server config) needs the end
|
||||
# client pinned to legacy until proxy era-mirroring lands.
|
||||
client = Client(server, mode=mode)
|
||||
client._transport_options = TransportOptions(
|
||||
session_class=_ForwardingClientSession
|
||||
)
|
||||
|
|
@ -1381,7 +1392,9 @@ class TestProxyForwardingAppliesToEveryBackendClient:
|
|||
config = MCPConfig.from_dict(
|
||||
{"mcpServers": {"a": {"url": url}, "b": {"url": url}}}
|
||||
)
|
||||
result = await self._forwarded(create_proxy(Client(config)), "a_status")
|
||||
result = await self._forwarded(
|
||||
create_proxy(Client(config)), "a_status", mode="legacy"
|
||||
)
|
||||
|
||||
assert result.is_error is False
|
||||
assert result.structured_content == {"status": "weird"}
|
||||
|
|
|
|||
|
|
@ -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!"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"""Tests for Context background task support (SEP-1686).
|
||||
|
||||
Tests Context API surface (unit) and background task elicitation (integration).
|
||||
Integration tests use Client(mcp) with the real memory:// Docket backend —
|
||||
Integration tests use Client(mcp, mode="legacy") with the real memory:// Docket backend —
|
||||
no mocking of Redis, Docket, or session internals.
|
||||
"""
|
||||
|
||||
|
|
@ -90,7 +90,7 @@ async def test_task_session_is_released_after_client_disconnect():
|
|||
async def work() -> str:
|
||||
return "done"
|
||||
|
||||
async with Client(mcp) as client:
|
||||
async with Client(mcp, mode="legacy") as client:
|
||||
task = await client.call_tool("work", task=True)
|
||||
await task.result()
|
||||
assert len(_task_sessions) == 1
|
||||
|
|
@ -357,7 +357,7 @@ class TestElicitFailFast:
|
|||
"fastmcp.server.tasks.notifications.push_notification",
|
||||
side_effect=ConnectionError("Redis queue unavailable"),
|
||||
):
|
||||
async with Client(mcp) as client:
|
||||
async with Client(mcp, mode="legacy") as client:
|
||||
task = await client.call_tool("failfast_tool", {}, task=True)
|
||||
await asyncio.wait_for(elicit_started.wait(), timeout=5.0)
|
||||
await task.wait(timeout=10.0)
|
||||
|
|
@ -389,14 +389,14 @@ class TestContextDocumentation:
|
|||
|
||||
|
||||
# =============================================================================
|
||||
# Integration tests: Client(mcp) + memory:// Docket backend
|
||||
# Integration tests: Client(mcp, mode="legacy") + memory:// Docket backend
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestBackgroundTaskIntegration:
|
||||
"""Integration tests for background task context using real Docket memory backend.
|
||||
|
||||
These tests use Client(mcp) with the memory:// broker — no mocking.
|
||||
These tests use Client(mcp, mode="legacy") with the memory:// broker — no mocking.
|
||||
The memory:// backend provides a fully functional in-memory Redis store
|
||||
that Docket uses automatically when running tests.
|
||||
"""
|
||||
|
|
@ -414,7 +414,7 @@ class TestBackgroundTaskIntegration:
|
|||
progress_reported.set()
|
||||
return "done"
|
||||
|
||||
async with Client(mcp) as client:
|
||||
async with Client(mcp, mode="legacy") as client:
|
||||
task = await client.call_tool("progress_tool", {}, task=True)
|
||||
await asyncio.wait_for(progress_reported.wait(), timeout=5.0)
|
||||
await task.wait(timeout=5.0)
|
||||
|
|
@ -435,7 +435,7 @@ class TestBackgroundTaskIntegration:
|
|||
task_completed.set()
|
||||
return "ok"
|
||||
|
||||
async with Client(mcp) as client:
|
||||
async with Client(mcp, mode="legacy") as client:
|
||||
task = await client.call_tool("verify_wiring", {}, task=True)
|
||||
await asyncio.wait_for(task_completed.wait(), timeout=5.0)
|
||||
await task.wait(timeout=5.0)
|
||||
|
|
@ -479,7 +479,7 @@ class TestBackgroundTaskIntegration:
|
|||
assert snapshot["origin_request_id"] == origin
|
||||
return "ok"
|
||||
|
||||
async with Client(mcp) as client:
|
||||
async with Client(mcp, mode="legacy") as client:
|
||||
task = await client.call_tool("check_origin_request_id", {}, task=True)
|
||||
result = await task.result()
|
||||
assert result.data == "ok"
|
||||
|
|
@ -514,7 +514,9 @@ class TestBackgroundTaskIntegration:
|
|||
stop_reason="endTurn",
|
||||
)
|
||||
|
||||
async with Client(mcp, sampling_handler=sampling_handler) as client:
|
||||
async with Client(
|
||||
mcp, mode="legacy", sampling_handler=sampling_handler
|
||||
) as client:
|
||||
task = await client.call_tool("ask_client", {}, task=True)
|
||||
result = await task.result()
|
||||
|
||||
|
|
@ -535,7 +537,7 @@ class TestBackgroundTaskIntegration:
|
|||
async def handler(message, response_type, params, ctx):
|
||||
return ElicitResult(action="accept", content={"value": "Bob"})
|
||||
|
||||
async with Client(mcp, elicitation_handler=handler) as client:
|
||||
async with Client(mcp, mode="legacy", elicitation_handler=handler) as client:
|
||||
task = await client.call_tool("ask_name", {}, task=True)
|
||||
await task.wait(timeout=10.0)
|
||||
result = await task.result()
|
||||
|
|
@ -557,7 +559,7 @@ class TestBackgroundTaskIntegration:
|
|||
async def handler(message, response_type, params, ctx):
|
||||
return ElicitResult(action="decline")
|
||||
|
||||
async with Client(mcp, elicitation_handler=handler) as client:
|
||||
async with Client(mcp, mode="legacy", elicitation_handler=handler) as client:
|
||||
task = await client.call_tool("optional_input", {}, task=True)
|
||||
await task.wait(timeout=10.0)
|
||||
result = await task.result()
|
||||
|
|
@ -583,7 +585,7 @@ class TestBackgroundTaskIntegration:
|
|||
async def handler(message, response_type, params, ctx):
|
||||
return ElicitResult(action="accept", content={"name": "Alice", "age": 30})
|
||||
|
||||
async with Client(mcp, elicitation_handler=handler) as client:
|
||||
async with Client(mcp, mode="legacy", elicitation_handler=handler) as client:
|
||||
task = await client.call_tool("get_user_info", {}, task=True)
|
||||
await task.wait(timeout=10.0)
|
||||
result = await task.result()
|
||||
|
|
@ -597,7 +599,7 @@ class TestBackgroundTaskIntegration:
|
|||
async def simple_tool() -> str:
|
||||
return "done"
|
||||
|
||||
async with Client(mcp) as client:
|
||||
async with Client(mcp, mode="legacy") as client:
|
||||
task = await client.call_tool("simple_tool", {}, task=True)
|
||||
await task.wait(timeout=5.0)
|
||||
|
||||
|
|
@ -615,7 +617,7 @@ class TestBackgroundTaskIntegration:
|
|||
class TestAccessTokenInBackgroundTasks:
|
||||
"""Tests for access token availability in background tasks (#3095).
|
||||
|
||||
Integration tests use Client(mcp) with the real memory:// Docket backend.
|
||||
Integration tests use Client(mcp, mode="legacy") with the real memory:// Docket backend.
|
||||
The token snapshot/restore round-trip flows through actual Redis (fakeredis).
|
||||
|
||||
Note: async tests run in isolated asyncio tasks, so ContextVar changes
|
||||
|
|
@ -641,7 +643,7 @@ class TestAccessTokenInBackgroundTasks:
|
|||
)
|
||||
auth_context_var.set(AuthenticatedUser(test_token))
|
||||
|
||||
async with Client(mcp) as client:
|
||||
async with Client(mcp, mode="legacy") as client:
|
||||
task = await client.call_tool("check_token", {}, task=True)
|
||||
result = await task.result()
|
||||
assert result.data == "roundtrip-jwt|test-client"
|
||||
|
|
@ -655,7 +657,7 @@ class TestAccessTokenInBackgroundTasks:
|
|||
token = get_access_token()
|
||||
return "no-token" if token is None else token.token
|
||||
|
||||
async with Client(mcp) as client:
|
||||
async with Client(mcp, mode="legacy") as client:
|
||||
task = await client.call_tool("check_token", {}, task=True)
|
||||
result = await task.result()
|
||||
assert result.data == "no-token"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -181,7 +181,9 @@ class TestSamplingCreateMessageSpan:
|
|||
result = await context.sample(messages=question)
|
||||
return result.text or ""
|
||||
|
||||
async with Client(mcp, sampling_handler=sampling_handler) as client:
|
||||
async with Client(
|
||||
mcp, mode="legacy", sampling_handler=sampling_handler
|
||||
) as client:
|
||||
await client.call_tool("ask", {"question": "hi"})
|
||||
|
||||
spans = _spans_named(trace_exporter, "sampling create_message")
|
||||
|
|
@ -220,7 +222,9 @@ class TestSamplingCreateMessageSpan:
|
|||
return result.text or ""
|
||||
|
||||
with pytest.raises(Exception):
|
||||
async with Client(mcp, sampling_handler=sampling_handler) as client:
|
||||
async with Client(
|
||||
mcp, mode="legacy", sampling_handler=sampling_handler
|
||||
) as client:
|
||||
await client.call_tool("ask", {"question": "hi"})
|
||||
|
||||
spans = _spans_named(trace_exporter, "sampling create_message")
|
||||
|
|
@ -339,7 +343,9 @@ class TestAttributesSurviveANonForwardingSampler:
|
|||
result = await context.sample(messages=question)
|
||||
return result.text or ""
|
||||
|
||||
async with Client(mcp, sampling_handler=sampling_handler) as client:
|
||||
async with Client(
|
||||
mcp, mode="legacy", sampling_handler=sampling_handler
|
||||
) as client:
|
||||
await client.call_tool("ask", {"question": "hi"})
|
||||
|
||||
spans = _spans_named(trace_exporter, "sampling create_message")
|
||||
|
|
@ -465,7 +471,9 @@ class TestAttributeRestoreRespectsSampler:
|
|||
result = await context.sample(messages=question)
|
||||
return result.text or ""
|
||||
|
||||
async with Client(mcp, sampling_handler=sampling_handler) as client:
|
||||
async with Client(
|
||||
mcp, mode="legacy", sampling_handler=sampling_handler
|
||||
) as client:
|
||||
await client.call_tool("ask", {"question": "hi"})
|
||||
|
||||
spans = _spans_named(trace_exporter, "sampling create_message")
|
||||
|
|
@ -496,7 +504,9 @@ class TestAttributeRestoreRespectsSampler:
|
|||
result = await context.sample(messages=question)
|
||||
return result.text or ""
|
||||
|
||||
async with Client(mcp, sampling_handler=sampling_handler) as client:
|
||||
async with Client(
|
||||
mcp, mode="legacy", sampling_handler=sampling_handler
|
||||
) as client:
|
||||
await client.call_tool("ask", {"question": "hi"})
|
||||
|
||||
spans = _spans_named(trace_exporter, "sampling create_message")
|
||||
|
|
@ -647,7 +657,9 @@ class TestAttributeRestoreRespectsSampler:
|
|||
result = await context.sample(messages=question)
|
||||
return result.text or ""
|
||||
|
||||
async with Client(mcp, sampling_handler=sampling_handler) as client:
|
||||
async with Client(
|
||||
mcp, mode="legacy", sampling_handler=sampling_handler
|
||||
) as client:
|
||||
await client.call_tool("ask", {"question": "hi"})
|
||||
|
||||
spans = _spans_named(trace_exporter, "sampling create_message")
|
||||
|
|
@ -758,7 +770,9 @@ class TestRestoreDoesNotChurnAttributeLimitEvictions:
|
|||
result = await context.sample(messages=question)
|
||||
return result.text or ""
|
||||
|
||||
async with Client(mcp, sampling_handler=sampling_handler) as client:
|
||||
async with Client(
|
||||
mcp, mode="legacy", sampling_handler=sampling_handler
|
||||
) as client:
|
||||
await client.call_tool("ask", {"question": "hi"})
|
||||
|
||||
spans = _spans_named(exporter, "sampling create_message")
|
||||
|
|
|
|||
|
|
@ -449,7 +449,7 @@ class TestSeamServerSpan:
|
|||
):
|
||||
mcp = FastMCP("test-server")
|
||||
|
||||
async with Client(mcp) as client:
|
||||
async with Client(mcp, mode="legacy") as client:
|
||||
await client.set_logging_level("info")
|
||||
|
||||
spans = trace_exporter.get_finished_spans()
|
||||
|
|
@ -470,7 +470,7 @@ class TestSeamServerSpan:
|
|||
"""A seam-spanned method must produce exactly one SERVER span, not two."""
|
||||
mcp = FastMCP("test-server")
|
||||
|
||||
async with Client(mcp) as client:
|
||||
async with Client(mcp, mode="legacy") as client:
|
||||
await client.set_logging_level("info")
|
||||
|
||||
spans = trace_exporter.get_finished_spans()
|
||||
|
|
@ -745,10 +745,14 @@ class TestProtocolVersionAttribute:
|
|||
self, trace_exporter: InMemorySpanExporter
|
||||
):
|
||||
"""Seam-only methods (never reaching the high-level path) also carry the
|
||||
protocol version."""
|
||||
protocol version.
|
||||
|
||||
Pinned to legacy: `logging/setLevel` is a handshake-era seam method the
|
||||
modern (2026-07-28) protocol drops, so the span exists only on legacy.
|
||||
"""
|
||||
mcp = FastMCP("test-server")
|
||||
|
||||
async with Client(mcp) as client:
|
||||
async with Client(mcp, mode="legacy") as client:
|
||||
await client.set_logging_level("info")
|
||||
|
||||
spans = trace_exporter.get_finished_spans()
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ class TestServerIcons:
|
|||
)
|
||||
|
||||
# Verify that icons and website_url are passed to the underlying server
|
||||
async with Client(mcp) as client:
|
||||
async with Client(mcp, mode="legacy") as client:
|
||||
server_info = client.initialize_result.server_info
|
||||
assert server_info.website_url == "https://example.com"
|
||||
assert server_info.icons == icons
|
||||
|
|
@ -45,7 +45,7 @@ class TestServerIcons:
|
|||
"""Test that server works without icons and websiteUrl."""
|
||||
mcp = FastMCP(name="TestServer")
|
||||
|
||||
async with Client(mcp) as client:
|
||||
async with Client(mcp, mode="legacy") as client:
|
||||
server_info = client.initialize_result.server_info
|
||||
assert server_info.website_url is None
|
||||
assert server_info.icons is None
|
||||
|
|
@ -290,7 +290,7 @@ class TestIconTypes:
|
|||
|
||||
mcp = FastMCP("TestServer", icons=icons)
|
||||
|
||||
async with Client(mcp) as client:
|
||||
async with Client(mcp, mode="legacy") as client:
|
||||
server_info = client.initialize_result.server_info
|
||||
assert len(server_info.icons) == 3
|
||||
assert server_info.icons == icons
|
||||
|
|
@ -319,7 +319,7 @@ class TestIconTypes:
|
|||
|
||||
mcp = FastMCP("TestServer", icons=icons)
|
||||
|
||||
async with Client(mcp) as client:
|
||||
async with Client(mcp, mode="legacy") as client:
|
||||
server_info = client.initialize_result.server_info
|
||||
assert server_info.icons[0].src == "https://example.com/icon.png"
|
||||
assert server_info.icons[0].mime_type is None
|
||||
|
|
@ -336,7 +336,7 @@ class TestIconTheme:
|
|||
|
||||
mcp = FastMCP("TestServer", icons=icons)
|
||||
|
||||
async with Client(mcp) as client:
|
||||
async with Client(mcp, mode="legacy") as client:
|
||||
server_info = client.initialize_result.server_info
|
||||
assert server_info.icons[0].theme == theme
|
||||
|
||||
|
|
@ -346,7 +346,7 @@ class TestIconTheme:
|
|||
|
||||
mcp = FastMCP("TestServer", icons=icons)
|
||||
|
||||
async with Client(mcp) as client:
|
||||
async with Client(mcp, mode="legacy") as client:
|
||||
server_info = client.initialize_result.server_info
|
||||
assert server_info.icons[0].theme is None
|
||||
|
||||
|
|
|
|||
|
|
@ -992,7 +992,10 @@ class TestTaskExecution:
|
|||
request_state=None,
|
||||
)
|
||||
|
||||
async with Client(mcp) as client:
|
||||
# Client-side background-task submission (`task=True`) is the handshake-era
|
||||
# SEP-1686 model; in 2026-07-28 tasks moved to a separate extension, so pin
|
||||
# the era the "reject a guard's input-required from within a task" rule lives in.
|
||||
async with Client(mcp, mode="legacy") as client:
|
||||
task = await client.call_tool("book_flight", {}, task=True)
|
||||
with pytest.raises(MCPError, match="background task"):
|
||||
await task.result()
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -93,6 +93,47 @@ class InMemoryStdioMCPServer(StdioMCPServer):
|
|||
return FastMCPTransport(mcp=self.mcp)
|
||||
|
||||
|
||||
class TestConfigTransportLegacyOnly:
|
||||
"""`MCPConfigTransport.legacy_only` gating (regression for the over-broad flag).
|
||||
|
||||
A single-server config delegates directly to the underlying transport with no
|
||||
proxy, so it must mirror that transport's era capability rather than being
|
||||
forced legacy. Only the multi-server composite (backed by legacy-era
|
||||
ProxyClients) is legacy-only.
|
||||
"""
|
||||
|
||||
def test_single_modern_capable_server_is_not_forced_legacy(self):
|
||||
"""A single Streamable HTTP backend stays modern-capable under mode='auto'."""
|
||||
config = {
|
||||
"mcpServers": {"only": {"url": "https://example.com/mcp"}},
|
||||
}
|
||||
transport = MCPConfigTransport(config)
|
||||
assert isinstance(transport.transport, StreamableHttpTransport)
|
||||
assert transport.legacy_only is False
|
||||
|
||||
def test_single_sse_server_mirrors_legacy_only(self):
|
||||
"""A single SSE backend is legacy-only because SSE cannot serve modern."""
|
||||
config = {
|
||||
"mcpServers": {
|
||||
"only": {"url": "https://example.com/sse", "transport": "sse"}
|
||||
},
|
||||
}
|
||||
transport = MCPConfigTransport(config)
|
||||
assert isinstance(transport.transport, SSETransport)
|
||||
assert transport.legacy_only is True
|
||||
|
||||
def test_multi_server_config_is_legacy_only(self):
|
||||
"""A multi-server composite is legacy-only regardless of backend eras."""
|
||||
config = {
|
||||
"mcpServers": {
|
||||
"a": {"url": "https://a.example.com/mcp"},
|
||||
"b": {"url": "https://b.example.com/mcp"},
|
||||
},
|
||||
}
|
||||
transport = MCPConfigTransport(config)
|
||||
assert transport.legacy_only is True
|
||||
|
||||
|
||||
def test_parse_single_stdio_config():
|
||||
config = {
|
||||
"mcpServers": {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue