mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-09 07:09:11 +02:00
Merge pull request #4572 from PrefectHQ/feature/client-auto-default
Negotiate the best mutual protocol era by default
This commit is contained in:
commit
2bcfae3412
100 changed files with 1997 additions and 583 deletions
|
|
@ -37,7 +37,7 @@ async with Client(
|
|||
"https://your-server.fastmcp.app/mcp",
|
||||
auth="<your-token>",
|
||||
) as client:
|
||||
await client.ping()
|
||||
await client.list_tools()
|
||||
```
|
||||
|
||||
You can also supply a Bearer token to a transport instance, such as `StreamableHttpTransport` or `SSETransport`:
|
||||
|
|
@ -52,7 +52,7 @@ transport = StreamableHttpTransport(
|
|||
)
|
||||
|
||||
async with Client(transport) as client:
|
||||
await client.ping()
|
||||
await client.list_tools()
|
||||
```
|
||||
|
||||
## `BearerAuth` Helper
|
||||
|
|
@ -67,7 +67,7 @@ async with Client(
|
|||
"https://your-server.fastmcp.app/mcp",
|
||||
auth=BearerAuth(token="<your-token>"),
|
||||
) as client:
|
||||
await client.ping()
|
||||
await client.list_tools()
|
||||
```
|
||||
|
||||
## Custom Headers
|
||||
|
|
@ -84,5 +84,5 @@ async with Client(
|
|||
headers={"X-API-Key": "<your-token>"},
|
||||
),
|
||||
) as client:
|
||||
await client.ping()
|
||||
await client.list_tools()
|
||||
```
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ async with Client(
|
|||
client_metadata_url="https://myapp.example.com/oauth/client.json",
|
||||
),
|
||||
) as client:
|
||||
await client.ping()
|
||||
await client.list_tools()
|
||||
```
|
||||
|
||||
When the server supports CIMD, the client uses your metadata URL as its `client_id` instead of performing Dynamic Client Registration. The server fetches your document, validates it, and proceeds with the standard OAuth authorization flow.
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ from fastmcp import Client
|
|||
|
||||
# Uses default OAuth settings
|
||||
async with Client("https://your-server.fastmcp.app/mcp", auth="oauth") as client:
|
||||
await client.ping()
|
||||
await client.list_tools()
|
||||
```
|
||||
|
||||
|
||||
|
|
@ -44,7 +44,7 @@ from fastmcp.client.auth import OAuth
|
|||
oauth = OAuth(scopes=["user"])
|
||||
|
||||
async with Client("https://your-server.fastmcp.app/mcp", auth=oauth) as client:
|
||||
await client.ping()
|
||||
await client.list_tools()
|
||||
```
|
||||
|
||||
<Note>
|
||||
|
|
@ -125,7 +125,7 @@ encrypted_storage = FernetEncryptionWrapper(
|
|||
oauth = OAuth(token_storage=encrypted_storage)
|
||||
|
||||
async with Client("https://your-server.fastmcp.app/mcp", auth=oauth) as client:
|
||||
await client.ping()
|
||||
await client.list_tools()
|
||||
```
|
||||
|
||||
You can use any `AsyncKeyValue`-compatible backend from the [key-value library](https://github.com/strawgate/py-key-value) including Redis, DynamoDB, and more. Wrap your storage in `FernetEncryptionWrapper` for encryption.
|
||||
|
|
@ -150,7 +150,7 @@ async with Client(
|
|||
client_metadata_url="https://myapp.example.com/oauth/client.json",
|
||||
),
|
||||
) as client:
|
||||
await client.ping()
|
||||
await client.list_tools()
|
||||
```
|
||||
|
||||
See the [CIMD Authentication](/clients/auth/cimd) page for complete documentation on creating, hosting, and validating CIMD documents.
|
||||
|
|
@ -172,7 +172,7 @@ async with Client(
|
|||
client_secret="my-client-secret",
|
||||
),
|
||||
) as client:
|
||||
await client.ping()
|
||||
await client.list_tools()
|
||||
```
|
||||
|
||||
Public clients that rely on PKCE for security can omit `client_secret`:
|
||||
|
|
|
|||
|
|
@ -37,9 +37,6 @@ client = Client("my_mcp_server.py")
|
|||
|
||||
async def main():
|
||||
async with client:
|
||||
# Basic server interaction
|
||||
await client.ping()
|
||||
|
||||
# List available operations
|
||||
tools = await client.list_tools()
|
||||
resources = await client.list_resources()
|
||||
|
|
@ -171,20 +168,30 @@ 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)
|
||||
# Negotiate the newest era the server supports (the default)
|
||||
client = Client("https://example.com/mcp", mode="auto")
|
||||
```
|
||||
|
||||
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="legacy")
|
||||
```
|
||||
|
||||
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.
|
||||
Legacy mode is also what you need for the capabilities that depend on a live session between client and server. The handshake opens a persistent back-channel the server can push requests down, and the modern era removed it. Pin `mode="legacy"` when your code relies on any of these:
|
||||
|
||||
```python
|
||||
client = Client("https://example.com/mcp", mode="auto")
|
||||
```
|
||||
- **[Sampling](/clients/sampling)** — server-initiated LLM completion requests
|
||||
- **[Roots](/clients/roots)** — server-initiated requests for the client's roots
|
||||
- **[Elicitation](/clients/elicitation)** — server-initiated requests for user input, which modern connections replace with [input-required rounds](/clients/elicitation#input-required-rounds)
|
||||
- **[Background tasks](/clients/tasks)** — submitting an operation with `task=True`
|
||||
- `client.ping()` and `transport.get_session_id()`
|
||||
|
||||
A FastMCP server serves both eras, so a default client negotiates the modern one and these raise an era-specific error. Pinning the handshake restores them.
|
||||
|
||||
You can also pin a specific modern protocol version to adopt it directly, without a discovery probe:
|
||||
|
||||
|
|
@ -201,7 +208,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 +269,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.
|
||||
|
|
@ -301,6 +335,8 @@ See [Prompts](/clients/prompts) for detailed documentation including argument se
|
|||
|
||||
The client supports callback handlers for advanced server interactions. These let you respond to server-initiated requests and receive notifications.
|
||||
|
||||
Sampling, elicitation, and roots are all server-initiated, so they belong to the handshake era described under [protocol negotiation](#protocol-negotiation). A default client negotiates the newest era both peers share, where the server has no back-channel to push those requests down, so an example that exercises them pins `mode="legacy"`. Logging and progress arrive as notifications on the response stream and work in either era.
|
||||
|
||||
```python
|
||||
from fastmcp import Client
|
||||
from fastmcp.client.logging import LogMessage
|
||||
|
|
@ -317,6 +353,7 @@ async def sampling_handler(messages, params, context):
|
|||
|
||||
client = Client(
|
||||
"my_mcp_server.py",
|
||||
mode="legacy",
|
||||
log_handler=log_handler,
|
||||
progress_handler=progress_handler,
|
||||
sampling_handler=sampling_handler,
|
||||
|
|
|
|||
|
|
@ -13,6 +13,12 @@ Use this when you need to respond to server requests for user input during tool
|
|||
|
||||
Elicitation allows MCP servers to request structured input from users during operations. Instead of requiring all inputs upfront, servers can interactively ask for missing parameters, request clarification, or gather additional context.
|
||||
|
||||
Two routes reach that outcome, and the protocol version the client negotiates decides which one applies. On older versions the server pushes an elicitation request down to the client, over the connection the `initialize` handshake opens; that is the flow the next few sections describe. On `2026-07-28` and later the server instead returns a description of what it needs, and the client answers with a fresh call — see [input-required rounds](#input-required-rounds). You write the same `elicitation_handler` either way — FastMCP routes it to whichever mechanism the connection supports.
|
||||
|
||||
<Note>
|
||||
**This page shows the older protocol's elicitation flow.** On protocol version `2026-07-28` the server instead returns a description of what it needs and the client answers with a new call — see [input-required rounds](#input-required-rounds). The same `elicitation_handler` serves both. Clients default to `mode="auto"`, so the examples below pass `mode="legacy"` to exercise the server-initiated flow. See [protocol negotiation](/clients/client#protocol-negotiation).
|
||||
</Note>
|
||||
|
||||
## Handler Template
|
||||
|
||||
```python
|
||||
|
|
@ -54,6 +60,7 @@ async def elicitation_handler(
|
|||
|
||||
client = Client(
|
||||
"my_mcp_server.py",
|
||||
mode="legacy",
|
||||
elicitation_handler=elicitation_handler,
|
||||
)
|
||||
```
|
||||
|
|
@ -138,6 +145,7 @@ async def elicitation_handler(message, response_type, params, context):
|
|||
|
||||
client = Client(
|
||||
"my_mcp_server.py",
|
||||
mode="legacy",
|
||||
elicitation_handler=elicitation_handler
|
||||
)
|
||||
```
|
||||
|
|
@ -146,7 +154,7 @@ client = Client(
|
|||
|
||||
<VersionBadge version="4.0.0" />
|
||||
|
||||
On modern-era connections (protocol version `2026-07-28` and later), a server can ask for input before it returns a final result. Nothing is held open: the tool *returns* a description of what it needs, which completes that round as an ordinary response, and the client answers by issuing a **new** `call_tool`, `get_prompt`, or `read_resource` request carrying the answer. `fastmcp.Client` drives that loop for you — it fulfils each round's requests using the callbacks you already configured (your `elicitation_handler`, `sampling_handler`, and roots) and repeats until the call reaches a terminal result. No extra wiring is needed beyond the handlers described above.
|
||||
On protocol version `2026-07-28` and later, a server can ask for input before it returns a final result. Nothing is held open: the tool *returns* a description of what it needs, which completes that round as an ordinary response, and the client answers by issuing a **new** `call_tool`, `get_prompt`, or `read_resource` request carrying the answer. `fastmcp.Client` drives that loop for you — it fulfils each round's requests using the callbacks you already configured (your `elicitation_handler`, `sampling_handler`, and roots) and repeats until the call reaches a terminal result. No extra wiring is needed beyond the handlers described above.
|
||||
|
||||
The `input_required_max_rounds` parameter caps how many rounds the client will answer before giving up, guarding against a server that never terminates. It defaults to `10`.
|
||||
|
||||
|
|
|
|||
|
|
@ -13,6 +13,10 @@ Use this when you need to tell servers what local resources the client has acces
|
|||
|
||||
Roots inform servers about resources the client can provide. Servers can use this information to adjust behavior or provide more relevant responses.
|
||||
|
||||
<Note>
|
||||
**Roots require the older MCP protocol.** A server reads roots by sending a request down to the client, and protocol version `2026-07-28` removed the server's ability to do that. Clients default to `mode="auto"`, which negotiates the newest version both sides support, so the examples below pass `mode="legacy"`. See [protocol negotiation](/clients/client#protocol-negotiation).
|
||||
</Note>
|
||||
|
||||
## Static Roots
|
||||
|
||||
Provide a list of roots when creating the client:
|
||||
|
|
@ -22,6 +26,7 @@ from fastmcp import Client
|
|||
|
||||
client = Client(
|
||||
"my_mcp_server.py",
|
||||
mode="legacy",
|
||||
roots=["/path/to/root1", "/path/to/root2"]
|
||||
)
|
||||
```
|
||||
|
|
@ -40,6 +45,7 @@ async def roots_callback(context: RequestContext) -> list[str]:
|
|||
|
||||
client = Client(
|
||||
"my_mcp_server.py",
|
||||
mode="legacy",
|
||||
roots=roots_callback
|
||||
)
|
||||
```
|
||||
|
|
|
|||
|
|
@ -13,6 +13,10 @@ Use this when you need to respond to server requests for LLM completions.
|
|||
|
||||
MCP servers can request LLM completions from clients during tool execution. This enables servers to delegate AI reasoning to the client, which controls which LLM is used and how requests are made.
|
||||
|
||||
<Note>
|
||||
**Sampling requires the older MCP protocol.** A server requests sampling by sending a request down to the client, and protocol version `2026-07-28` removed the server's ability to do that. Clients default to `mode="auto"`, which negotiates the newest version both sides support, so every example on this page passes `mode="legacy"`. See [protocol negotiation](/clients/client#protocol-negotiation).
|
||||
</Note>
|
||||
|
||||
## Handler Template
|
||||
|
||||
```python
|
||||
|
|
@ -49,6 +53,7 @@ async def sampling_handler(
|
|||
|
||||
client = Client(
|
||||
"my_mcp_server.py",
|
||||
mode="legacy",
|
||||
sampling_handler=sampling_handler,
|
||||
)
|
||||
```
|
||||
|
|
@ -109,6 +114,7 @@ from fastmcp.client.sampling.handlers.openai import OpenAISamplingHandler
|
|||
|
||||
client = Client(
|
||||
"my_mcp_server.py",
|
||||
mode="legacy",
|
||||
sampling_handler=OpenAISamplingHandler(default_model="gpt-4o"),
|
||||
)
|
||||
```
|
||||
|
|
@ -120,6 +126,7 @@ from openai import AsyncOpenAI
|
|||
|
||||
client = Client(
|
||||
"my_mcp_server.py",
|
||||
mode="legacy",
|
||||
sampling_handler=OpenAISamplingHandler(
|
||||
default_model="llama-3.1-70b",
|
||||
client=AsyncOpenAI(base_url="http://localhost:8000/v1"),
|
||||
|
|
@ -141,6 +148,7 @@ from fastmcp.client.sampling.handlers.anthropic import AnthropicSamplingHandler
|
|||
|
||||
client = Client(
|
||||
"my_mcp_server.py",
|
||||
mode="legacy",
|
||||
sampling_handler=AnthropicSamplingHandler(default_model="claude-sonnet-4-5"),
|
||||
)
|
||||
```
|
||||
|
|
@ -159,6 +167,7 @@ from fastmcp.client.sampling.handlers.google_genai import GoogleGenaiSamplingHan
|
|||
|
||||
client = Client(
|
||||
"my_mcp_server.py",
|
||||
mode="legacy",
|
||||
sampling_handler=GoogleGenaiSamplingHandler(default_model="gemini-2.0-flash"),
|
||||
)
|
||||
```
|
||||
|
|
@ -176,6 +185,7 @@ from fastmcp.types import SamplingCapability
|
|||
|
||||
client = Client(
|
||||
"my_mcp_server.py",
|
||||
mode="legacy",
|
||||
sampling_handler=basic_handler,
|
||||
sampling_capabilities=SamplingCapability(), # No tool support
|
||||
)
|
||||
|
|
|
|||
|
|
@ -14,6 +14,10 @@ Use this when you need to run long operations asynchronously while doing other w
|
|||
|
||||
The MCP task protocol lets you request operations to run in the background. The call returns a Task object immediately, letting you track progress, cancel operations, or await results.
|
||||
|
||||
<Note>
|
||||
**Background tasks require the older MCP protocol.** FastMCP submits a task over the session that the `initialize` handshake opens, and protocol version `2026-07-28` has no equivalent. Clients default to `mode="auto"`, which negotiates the newest version both sides support, so the examples on this page pass `mode="legacy"`. See [protocol negotiation](/clients/client#protocol-negotiation).
|
||||
</Note>
|
||||
|
||||
## Requesting Background Execution
|
||||
|
||||
Pass `task=True` to run an operation as a background task:
|
||||
|
|
@ -21,7 +25,7 @@ Pass `task=True` to run an operation as a background task:
|
|||
```python
|
||||
from fastmcp import Client
|
||||
|
||||
async with Client(server) as client:
|
||||
async with Client(server, mode="legacy") as client:
|
||||
# Start a background task
|
||||
task = await client.call_tool("slow_computation", {"duration": 10}, task=True)
|
||||
|
||||
|
|
@ -154,7 +158,7 @@ import asyncio
|
|||
from fastmcp import Client
|
||||
|
||||
async def main():
|
||||
async with Client(server) as client:
|
||||
async with Client(server, mode="legacy") as client:
|
||||
# Start background task
|
||||
task = await client.call_tool(
|
||||
"slow_computation",
|
||||
|
|
|
|||
|
|
@ -86,7 +86,7 @@ client = Client(transport)
|
|||
|
||||
async def efficient_multiple_operations():
|
||||
async with client:
|
||||
await client.ping()
|
||||
await client.list_tools()
|
||||
|
||||
async with client: # Reuses the same subprocess
|
||||
await client.call_tool("process_data", {"file": "data.csv"})
|
||||
|
|
|
|||
|
|
@ -369,7 +369,7 @@ async def test_tenant_header_is_visible_to_tools():
|
|||
headers={"X-Tenant-ID": "acme"},
|
||||
timeout=5,
|
||||
) as client:
|
||||
await client.ping()
|
||||
await client.list_tools()
|
||||
```
|
||||
|
||||
#### Sharing One Server Across Tests
|
||||
|
|
@ -398,11 +398,16 @@ async def test_greet(http_server: ASGIServer):
|
|||
assert greeting.data == "Hello, World!"
|
||||
|
||||
async def test_sessions_are_isolated(http_server: ASGIServer):
|
||||
async with http_server.client() as first, http_server.client() as second:
|
||||
async with (
|
||||
http_server.client(mode="legacy") as first,
|
||||
http_server.client(mode="legacy") as second,
|
||||
):
|
||||
assert await first.ping() is True
|
||||
assert await second.ping() is True
|
||||
```
|
||||
|
||||
Sessions belong to the handshake era of the MCP protocol, and so does `ping`, so a test that is about session behavior pins `mode="legacy"`. Every keyword argument `client()` doesn't consume itself is passed straight to `Client`. See [protocol negotiation](/clients/client#protocol-negotiation).
|
||||
|
||||
For assertions about raw HTTP — status codes, response headers, metadata endpoints — `http_client()` returns an `httpx.AsyncClient` bound to the same app. Because nothing is listening on the network, this is the only way to make raw requests; a plain `httpx.AsyncClient()` cannot reach the server.
|
||||
|
||||
```python
|
||||
|
|
@ -430,7 +435,7 @@ async def test_server_binds_a_real_port():
|
|||
async with run_server_async(server) as url:
|
||||
assert url.startswith("http://127.0.0.1:")
|
||||
async with Client(url) as client:
|
||||
assert await client.ping() is True
|
||||
assert await client.list_tools() == []
|
||||
```
|
||||
|
||||
#### Subprocess Testing (Special Cases)
|
||||
|
|
@ -464,8 +469,8 @@ async def test_http_transport(http_server: str):
|
|||
async with Client(
|
||||
transport=StreamableHttpTransport(http_server)
|
||||
) as client:
|
||||
result = await client.ping()
|
||||
assert result is True
|
||||
tools = await client.list_tools()
|
||||
assert "greet" in [tool.name for tool in tools]
|
||||
```
|
||||
|
||||
The `run_server_in_process` utility handles server lifecycle, port allocation, and cleanup automatically. Use this only when subprocess isolation is truly necessary, as it's slower and harder to debug than in-process testing. FastMCP uses the `client_process` marker to isolate these tests in CI.
|
||||
|
|
|
|||
|
|
@ -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. |
|
||||
|
|
|
|||
|
|
@ -180,6 +180,10 @@ Sampling is the exception that does not come back, and the reason is the protoco
|
|||
| `ctx.elicit` | Supported | Use the guard pattern (return `InputRequiredResult`) |
|
||||
| `ctx.sample` / `ctx.sample_step` | Supported (deprecated) | Removed — call an LLM server-side |
|
||||
| `ctx.list_roots` | Supported | Via the guard pattern (`input_requests` carries roots requests) |
|
||||
| `Middleware.on_initialize` | Runs on connect | Never runs — there is no `initialize` handshake |
|
||||
| Session state (`ctx.set_state` across calls) | Persists for the session | Does not persist — every request is a fresh connection |
|
||||
| Tasks (via the FastMCP client) | Supported | Not yet |
|
||||
|
||||
If your tools rely on `ctx.elicit` or `ctx.list_roots`, they continue to work against clients on the earlier eras; on the modern era, reach for the guard pattern instead (see [Elicitation on the modern protocol](/servers/elicitation#elicitation-on-the-modern-protocol)). Sampling is deprecated on every era and will not return on modern connections — migrate those tools to server-side LLM calls.
|
||||
|
||||
Two of these bite by default now, because **`fastmcp.Client` defaults to `mode="auto"`** in v4 — an ordinary `Client(server)` negotiates the newest protocol both sides share, which against a FastMCP server is the sessionless `2026-07-28` era. On that era there is no `initialize` handshake, so a `Middleware.on_initialize` hook never runs; and each request is a fresh connection, so state written with `ctx.set_state` in one call is not visible in the next. A server that gates access in `on_initialize` or relies on per-session state must keep its clients on the session-based era. The narrow escape is per-client: `Client(server, mode="legacy")`. The durable, server-side answer is to declare the versions the server actually serves so a modern client is refused at connect time rather than silently losing those features — see the server's protocol-version restriction (added alongside this change).
|
||||
|
|
|
|||
|
|
@ -81,7 +81,8 @@ auth = OAuth(additional_client_metadata={"token_endpoint_auth_method": "none"})
|
|||
|
||||
async def main():
|
||||
async with Client("http://127.0.0.1:8000/mcp", auth=auth) as client:
|
||||
assert await client.ping()
|
||||
tools = await client.list_tools()
|
||||
print(f"Authenticated. Server exposes {len(tools)} tools.")
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
|
|
|
|||
|
|
@ -116,7 +116,8 @@ import asyncio
|
|||
|
||||
async def main():
|
||||
async with Client("http://localhost:8000/mcp", auth="oauth") as client:
|
||||
assert await client.ping()
|
||||
tools = await client.list_tools()
|
||||
print(f"Authenticated. Server exposes {len(tools)} tools.")
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
|
|
|
|||
|
|
@ -101,7 +101,8 @@ import asyncio
|
|||
|
||||
async def main():
|
||||
async with Client("http://localhost:8000/mcp", auth="oauth") as client:
|
||||
assert await client.ping()
|
||||
tools = await client.list_tools()
|
||||
print(f"Authenticated. Server exposes {len(tools)} tools.")
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
|
|
|
|||
|
|
@ -97,10 +97,15 @@ def create_client_server(url: str) -> Any:
|
|||
A FastMCP server instance
|
||||
"""
|
||||
try:
|
||||
import fastmcp
|
||||
|
||||
client = fastmcp.Client(url)
|
||||
server = create_proxy(client)
|
||||
# Hand `create_proxy` the URL rather than a pre-built `Client`. A Client
|
||||
# target is treated as caller-configured and pinned, so its era would be
|
||||
# fixed at construction — and since `Client` now defaults to `"auto"`,
|
||||
# that would pin this proxy's upstream to the modern era and break
|
||||
# handshake-era clients connecting to it (`ping`, server-initiated
|
||||
# forwarding). Passing the URL lets the proxy mirror each front
|
||||
# connection's negotiated era instead, so `fastmcp run <URL>` serves
|
||||
# both eras.
|
||||
server = create_proxy(url)
|
||||
return server
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create client for URL {url}: {e}")
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import secrets
|
|||
import ssl
|
||||
import uuid
|
||||
import weakref
|
||||
from collections.abc import Callable, Coroutine
|
||||
from collections.abc import AsyncIterator, Callable, Coroutine, Mapping, Sequence
|
||||
from contextlib import AsyncExitStack, asynccontextmanager, suppress
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
|
@ -31,15 +31,21 @@ 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,
|
||||
TaskStatusNotification,
|
||||
TaskStatusNotificationParams,
|
||||
)
|
||||
from mcp_types.methods import validate_server_result
|
||||
from mcp_types.version import HANDSHAKE_PROTOCOL_VERSIONS, MODERN_PROTOCOL_VERSIONS
|
||||
from pydantic import AnyUrl
|
||||
from pydantic import AnyUrl, ValidationError
|
||||
|
||||
import fastmcp as fastmcp
|
||||
from fastmcp.client.auth.oauth import OAuth
|
||||
|
|
@ -121,11 +127,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 +155,154 @@ def _synthesize_discover(protocol_version: str) -> mcp_types.DiscoverResult:
|
|||
)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def _conformant_discover_only(
|
||||
session: ClientSession,
|
||||
) -> AsyncIterator[None]:
|
||||
"""Hold ``session.send_discover`` to the same wire schema every later reply must meet.
|
||||
|
||||
``negotiate_auto`` accepts a probe that parses as the version-free
|
||||
``DiscoverResult``, whose ``resultType``/``ttlMs``/``cacheScope`` all carry
|
||||
SDK-side defaults. Every request *after* adoption is instead checked against
|
||||
the strict per-version surface (``validate_server_result``), where those same
|
||||
three fields are required. A server that answers ``server/discover`` without
|
||||
them therefore passes the probe and then fails every subsequent call — the
|
||||
connection is adopted into an era the peer cannot actually serve.
|
||||
|
||||
Closing that gap means judging the probe by the rule that will govern the rest
|
||||
of the connection. A result that would be rejected later is not positive
|
||||
evidence of a modern peer, so it is reported as an ordinary probe failure and
|
||||
``negotiate_auto`` falls back to the initialize handshake, exactly as it does
|
||||
for a server with no ``server/discover`` at all.
|
||||
"""
|
||||
send_discover = session.send_discover
|
||||
|
||||
async def _checked_send_discover(version: str) -> dict[str, Any]:
|
||||
raw = await send_discover(version)
|
||||
try:
|
||||
validate_server_result("server/discover", version, raw)
|
||||
except ValidationError as e:
|
||||
# Ordered before the ValueError arm below: pydantic's ValidationError
|
||||
# subclasses ValueError, so a broader clause first would swallow it.
|
||||
logger.debug(
|
||||
"server/discover at %s is not %s-conformant (%s); "
|
||||
"falling back to the initialize handshake",
|
||||
version,
|
||||
version,
|
||||
e,
|
||||
)
|
||||
raise MCPError(
|
||||
code=mcp_types.INVALID_PARAMS,
|
||||
message=(
|
||||
f"server/discover result is not conformant with {version}; "
|
||||
"treating the server as handshake-era"
|
||||
),
|
||||
) from e
|
||||
except (KeyError, ValueError):
|
||||
# No schema on file for this method/version pair, so there is nothing to
|
||||
# judge the probe against; leave the verdict to negotiate_auto's parse.
|
||||
return raw
|
||||
return raw
|
||||
|
||||
# A transport may itself have installed a `send_discover` override, so restore
|
||||
# whatever was there rather than assuming the class attribute.
|
||||
had_own = "send_discover" in vars(session)
|
||||
session.send_discover = _checked_send_discover # ty: ignore[invalid-assignment]
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
if had_own:
|
||||
session.send_discover = send_discover # ty: ignore[invalid-assignment]
|
||||
else:
|
||||
del session.send_discover
|
||||
|
||||
|
||||
@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 +413,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 +431,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 +533,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 +623,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 +638,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 +862,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,14 +920,22 @@ 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":
|
||||
await negotiate_auto(self.session)
|
||||
elif effective_mode == "auto":
|
||||
async with _conformant_discover_only(self.session):
|
||||
await negotiate_auto(self.session)
|
||||
# auto may have fallen back to the legacy handshake; surface its
|
||||
# InitializeResult through the existing public property when so.
|
||||
self._session_state.initialize_result = (
|
||||
|
|
@ -782,7 +965,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 +1353,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,26 @@ 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.
|
||||
# Resolution issues further session requests of its own (result
|
||||
# validation lists tools; a resolver may make more), so it needs
|
||||
# the same session monitoring as the calls above — otherwise a
|
||||
# transport-level failure can kill the session runner while this
|
||||
# await waits forever.
|
||||
result = await self._await_with_session_monitoring(
|
||||
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)
|
||||
|
|
@ -78,6 +80,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(
|
||||
|
|
|
|||
|
|
@ -110,6 +110,21 @@ class MCPConfigTransport(ClientTransport):
|
|||
|
||||
self._request_state_security = _RequestStateSecurity.ephemeral()
|
||||
|
||||
@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:
|
||||
|
|
@ -162,7 +164,16 @@ class _TransformingMCPServerMixin(BaseModel):
|
|||
)
|
||||
) from exc
|
||||
|
||||
return FastMCPTransport(mcp=self._to_server_and_underlying_transport()[0])
|
||||
transport = FastMCPTransport(mcp=self._to_server_and_underlying_transport()[0])
|
||||
# The wrapped proxy talks to its upstream over the legacy era (it pins
|
||||
# the backend client to `mode="legacy"` to forward the initialize
|
||||
# handshake and server-initiated features). Mark the wrapper legacy-only
|
||||
# so a default `Client(config)` on `mode="auto"` negotiates legacy with
|
||||
# it too, keeping both legs on the same era — otherwise a modern
|
||||
# frontend would receive a forwarded server-initiated request that the
|
||||
# modern era has no back-channel for.
|
||||
transport.legacy_only = True
|
||||
return transport
|
||||
|
||||
|
||||
class StdioMCPServer(BaseModel):
|
||||
|
|
|
|||
|
|
@ -461,13 +461,9 @@ class LowLevelServer(_Server[LifespanResultT]):
|
|||
# ensure we use the FastMCP notification options
|
||||
if notification_options is None:
|
||||
notification_options = self.notification_options
|
||||
merged = {
|
||||
**self.fastmcp.experimental_capabilities,
|
||||
**(experimental_capabilities or {}),
|
||||
}
|
||||
return super().create_initialization_options(
|
||||
notification_options=notification_options,
|
||||
experimental_capabilities=merged or None,
|
||||
experimental_capabilities=experimental_capabilities,
|
||||
extensions=extensions,
|
||||
)
|
||||
|
||||
|
|
@ -483,13 +479,23 @@ class LowLevelServer(_Server[LifespanResultT]):
|
|||
and advertise the MCP Apps UI extension.
|
||||
|
||||
``ServerCapabilities.tasks`` and ``ServerCapabilities.extensions`` are
|
||||
real declared fields in v2, so we update them directly.
|
||||
real declared fields in v2, so we update them directly. The
|
||||
`FastMCP(experimental_capabilities=...)` merge also lives here rather
|
||||
than in `create_initialization_options`: the modern `server/discover`
|
||||
handler calls this directly, without going through
|
||||
`create_initialization_options` at all, so merging there only reached
|
||||
the handshake-era `initialize` response and silently dropped
|
||||
constructor-configured experimental capabilities from `discover`.
|
||||
"""
|
||||
from fastmcp.server.tasks.capabilities import get_task_capabilities
|
||||
|
||||
merged_experimental = {
|
||||
**self.fastmcp.experimental_capabilities,
|
||||
**(experimental_capabilities or {}),
|
||||
}
|
||||
capabilities = super().get_capabilities(
|
||||
notification_options,
|
||||
experimental_capabilities,
|
||||
merged_experimental or None,
|
||||
extensions,
|
||||
protocol_version=protocol_version,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -71,6 +71,15 @@ class PingMiddleware(Middleware):
|
|||
ping_task.cancel()
|
||||
with contextlib.suppress(asyncio.CancelledError):
|
||||
await ping_task
|
||||
# `ping_task` may be cancelled before its first
|
||||
# scheduler turn (a connection can be built and torn
|
||||
# down within a single request on the modern,
|
||||
# per-request `Connection` path), in which case its
|
||||
# body - and the `finally` in `_ping_loop` that would
|
||||
# otherwise discard this entry - never runs. Discard
|
||||
# unconditionally here so a connection that closes
|
||||
# before the loop starts doesn't leak its entry.
|
||||
self._active_sessions.discard(connection_id)
|
||||
|
||||
connection.exit_stack.push_async_callback(_cancel_ping)
|
||||
|
||||
|
|
|
|||
|
|
@ -1377,6 +1377,13 @@ class ProxyClient(Client[ClientTransportT]):
|
|||
# request, so the whole chain speaks one era end-to-end. An explicit
|
||||
# `mode=` (e.g. `create_proxy(target, mode="auto")`) pins the era and
|
||||
# overrides mirroring. The eras are mutually exclusive per session.
|
||||
#
|
||||
# The handshake default is pinned explicitly rather than inherited from
|
||||
# `Client`, whose own default is `"auto"`: mirroring only applies when
|
||||
# there is a front request to mirror, so this is the fallback for a
|
||||
# directly-constructed ProxyClient, and it must not drift with the
|
||||
# client default.
|
||||
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,14 @@ 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.
|
||||
|
||||
`_on_get_prompt` in fastmcp_slim/fastmcp/server/mixins/mcp_operations.py
|
||||
catches `FastMCPError` broadly and translates it into an `MCPError` via
|
||||
`to_mcp_error`, the same way `_on_call_tool` surfaces tool errors. The
|
||||
`PromptError` raised during argument conversion reaches the client with
|
||||
its message intact on both protocol eras.
|
||||
"""
|
||||
|
||||
server = FastMCP("TestServer")
|
||||
|
||||
|
|
@ -341,7 +348,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 +360,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 +369,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 +404,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 +414,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,14 @@
|
|||
"""Client error handling tests."""
|
||||
"""Client error handling tests.
|
||||
|
||||
Resource, resource-template, and prompt error *detail* surfacing is
|
||||
era-neutral. `_on_read_resource` / `_on_get_prompt` in
|
||||
`fastmcp_slim/fastmcp/server/mixins/mcp_operations.py` catch `FastMCPError`
|
||||
broadly and translate it into an `MCPError` via `to_mcp_error`, mirroring how
|
||||
`_on_call_tool` returns tool errors as an `isError` `CallToolResult`. The
|
||||
detailed message (a `ResourceError`/`PromptError`, or the `ResourceError`/
|
||||
`PromptError` that wraps an arbitrary handler exception) reaches the client
|
||||
on the default `auto` mode exactly as it does on `mode="legacy"`.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
|
|
|
|||
|
|
@ -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,146 @@ 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 TestNonConformantModernPeer:
|
||||
"""A peer that answers ``server/discover`` but cannot actually serve the era.
|
||||
|
||||
``negotiate_auto`` accepts a probe that parses as the version-free
|
||||
``DiscoverResult``, where ``resultType``/``ttlMs``/``cacheScope`` all carry
|
||||
SDK-side defaults. Every request after adoption is checked against the strict
|
||||
per-version surface, where those three fields are required. Left alone, a
|
||||
server that omits them on ``server/discover`` passes the probe and then fails
|
||||
every subsequent call, so ``auto`` would adopt an era the peer cannot serve.
|
||||
|
||||
GitHub's remote MCP server is a live example: it has adopted the SEP-2549
|
||||
cache fields but not result tagging, so it answers ``server/discover``
|
||||
with ``ttlMs``/``cacheScope`` and no ``resultType``.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def _discover_body(**envelope: Any) -> dict[str, Any]:
|
||||
return {
|
||||
"supportedVersions": [LATEST_MODERN_VERSION],
|
||||
"capabilities": {"tools": {}, "resources": {}, "prompts": {}},
|
||||
"serverInfo": {"name": "TestServer", "version": "1.0"},
|
||||
**envelope,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _transport_answering(body: dict[str, Any], server) -> FastMCPTransport:
|
||||
"""An in-memory transport whose ``server/discover`` returns ``body`` verbatim."""
|
||||
|
||||
class _FixedDiscoverTransport(FastMCPTransport):
|
||||
@contextlib.asynccontextmanager
|
||||
async def connect_session(
|
||||
self, **session_kwargs: Unpack[SessionKwargs]
|
||||
) -> AsyncIterator[ClientSession]:
|
||||
async with super().connect_session(**session_kwargs) as session:
|
||||
|
||||
async def _fixed_discover(version: str) -> dict[str, Any]:
|
||||
return body
|
||||
|
||||
session.send_discover = _fixed_discover # ty: ignore[invalid-assignment]
|
||||
yield session
|
||||
|
||||
return _FixedDiscoverTransport(server)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"envelope",
|
||||
[
|
||||
pytest.param({}, id="no-envelope-fields"),
|
||||
pytest.param(
|
||||
{"ttlMs": 0, "cacheScope": "private"}, id="github-shape-no-resultType"
|
||||
),
|
||||
pytest.param({"resultType": "complete"}, id="no-cache-fields"),
|
||||
],
|
||||
)
|
||||
async def test_non_conformant_discover_falls_back_to_handshake(
|
||||
self, fastmcp_server, envelope
|
||||
):
|
||||
"""A discover result missing required 2026-07-28 fields is not modern evidence.
|
||||
|
||||
Rather than adopting an era the peer cannot serve, auto degrades to the
|
||||
initialize handshake and the connection stays fully usable.
|
||||
"""
|
||||
transport = self._transport_answering(
|
||||
self._discover_body(**envelope), fastmcp_server
|
||||
)
|
||||
async with Client(transport, mode="auto") as client:
|
||||
assert client.protocol_version == LATEST_HANDSHAKE_VERSION
|
||||
assert client.initialize_result is not None
|
||||
# The connection works, which is the whole point of degrading.
|
||||
assert await client.list_tools()
|
||||
result = await client.call_tool("add", {"a": 1, "b": 2})
|
||||
assert result.data == 3
|
||||
|
||||
async def test_conformant_discover_still_adopts_modern(self, fastmcp_server):
|
||||
"""The conformance check must not reject a well-formed modern peer."""
|
||||
transport = self._transport_answering(
|
||||
self._discover_body(resultType="complete", ttlMs=0, cacheScope="private"),
|
||||
fastmcp_server,
|
||||
)
|
||||
async with Client(transport, mode="auto") as client:
|
||||
assert client.protocol_version == LATEST_MODERN_VERSION
|
||||
assert client.initialize_result is None
|
||||
result = await client.call_tool("add", {"a": 1, "b": 2})
|
||||
assert result.data == 3
|
||||
|
||||
|
||||
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
|
||||
|
|
|
|||
|
|
@ -54,7 +54,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)
|
||||
|
|
@ -66,7 +66,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)
|
||||
|
|
@ -90,7 +90,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
|
||||
|
|
@ -123,7 +123,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
|
||||
|
|
@ -150,7 +150,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)
|
||||
|
|
@ -176,7 +176,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)
|
||||
|
|
@ -194,7 +194,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
|
||||
|
|
@ -211,7 +211,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):
|
||||
|
|
@ -242,7 +242,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))
|
||||
|
||||
|
|
@ -257,7 +257,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", "")
|
||||
|
|
@ -76,7 +76,7 @@ async def test_task_id_operations_create_propagating_client_spans(
|
|||
await asyncio.Event().wait()
|
||||
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"})
|
||||
|
||||
|
|
|
|||
332
tests/client/test_client_extensions.py
Normal file
332
tests/client/test_client_extensions.py
Normal file
|
|
@ -0,0 +1,332 @@
|
|||
"""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 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,6 +222,8 @@ 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"})
|
||||
|
||||
# Not pinned: response_title is validated locally before any request is
|
||||
# dispatched, so this raises identically on every era.
|
||||
async with Client(mcp, elicitation_handler=elicitation_handler) as client:
|
||||
with pytest.raises(ToolError, match="response_title"):
|
||||
await client.call_tool("ask", {})
|
||||
|
|
@ -235,6 +245,8 @@ async def test_elicitation_response_title_rejected_for_none():
|
|||
async def elicitation_handler(message, response_type, params, ctx):
|
||||
return ElicitResult(action="accept", content={})
|
||||
|
||||
# Not pinned: response_title is validated locally before any request is
|
||||
# dispatched, so this raises identically on every era.
|
||||
async with Client(mcp, 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,13 @@ 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:
|
||||
# Pinned: the tool's broad `except Exception` means this would pass under
|
||||
# auto for the wrong reason (elicit() itself raising "unavailable on
|
||||
# 2026-07-28" rather than the handler's ValueError ever running). Legacy
|
||||
# pins the test to what it actually claims to exercise.
|
||||
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 +589,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 +663,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 +712,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 +794,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 +821,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 +848,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,11 @@ 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:
|
||||
# client.set_logging_level is a legacy-only RPC (deprecated per SEP-2577);
|
||||
# it does not exist on the modern protocol.
|
||||
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 +119,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 +175,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(
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -149,7 +149,8 @@ async def nested_server():
|
|||
|
||||
async def test_ping(streamable_http_server: ASGIServer):
|
||||
"""Test pinging the server."""
|
||||
async with streamable_http_server.client() as client:
|
||||
# `ping` is a handshake-era method, so this pins the legacy era.
|
||||
async with streamable_http_server.client(mode="legacy") as client:
|
||||
result = await client.ping()
|
||||
assert result is True
|
||||
|
||||
|
|
@ -158,7 +159,10 @@ async def test_ping_with_streamable_http_alias(
|
|||
streamable_http_server_with_streamable_http_alias: ASGIServer,
|
||||
):
|
||||
"""Test pinging the server."""
|
||||
async with streamable_http_server_with_streamable_http_alias.client() as client:
|
||||
# `ping` is a handshake-era method, so this pins the legacy era.
|
||||
async with streamable_http_server_with_streamable_http_alias.client(
|
||||
mode="legacy"
|
||||
) as client:
|
||||
result = await client.ping()
|
||||
assert result is True
|
||||
|
||||
|
|
@ -179,7 +183,7 @@ async def test_session_id_callback(streamable_http_server: ASGIServer):
|
|||
"""Test getting mcp-session-id from the transport."""
|
||||
transport = streamable_http_server.transport()
|
||||
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
|
||||
|
||||
|
|
@ -214,8 +218,9 @@ async def test_elicitation_tool(streamable_http_server: ASGIServer, request):
|
|||
if stateless_http:
|
||||
pytest.xfail("Elicitation is not supported in stateless HTTP mode")
|
||||
|
||||
# Server-initiated elicitation is handshake-era only.
|
||||
async with streamable_http_server.client(
|
||||
elicitation_handler=elicitation_handler
|
||||
elicitation_handler=elicitation_handler, mode="legacy"
|
||||
) as client:
|
||||
result = await client.call_tool("elicit")
|
||||
assert result.data == "You said your name was: Alice!"
|
||||
|
|
@ -241,7 +246,9 @@ async def test_stateless_http_still_accepts_post(
|
|||
|
||||
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
|
||||
|
||||
|
|
|
|||
|
|
@ -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,9 @@ 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:
|
||||
# `ctx.elicit` sends a server-initiated request down the client's
|
||||
# back-channel, which only the older protocol has, so this pins that era.
|
||||
async with Client(
|
||||
mcp, mode="legacy", elicitation_handler=elicitation_handler
|
||||
) as client:
|
||||
await client.call_tool("my_tool", {})
|
||||
|
|
|
|||
|
|
@ -359,8 +359,10 @@ async def test_github_oauth_with_mock(github_client_with_mock: Client):
|
|||
"""Test complete GitHub OAuth flow with mocked callback."""
|
||||
|
||||
async with github_client_with_mock:
|
||||
# Test that we can ping the server (requires successful OAuth)
|
||||
assert await github_client_with_mock.ping()
|
||||
# Reaching the server at all requires successful OAuth. `list_tools` stands
|
||||
# in for `ping` here because it works in either protocol era, and a default
|
||||
# client negotiates the modern one, which has no `ping` method.
|
||||
assert await github_client_with_mock.list_tools()
|
||||
|
||||
# Test that we can call protected tools
|
||||
result = await github_client_with_mock.call_tool("get_protected_data", {})
|
||||
|
|
|
|||
|
|
@ -24,6 +24,13 @@ pytestmark = pytest.mark.xfail(
|
|||
|
||||
@pytest.fixture(name="streamable_http_client")
|
||||
def fixture_streamable_http_client() -> Client[StreamableHttpTransport]:
|
||||
"""A default client, so this suite exercises `mode="auto"` against a real peer.
|
||||
|
||||
GitHub answers `server/discover` but has not adopted result tagging, so its
|
||||
result envelope is not conformant with the modern version it advertises. The
|
||||
client's conformance check catches that at connect time and degrades to the
|
||||
initialize handshake, which is why these tests behave as they always have.
|
||||
"""
|
||||
assert FASTMCP_GITHUB_TOKEN is not None
|
||||
|
||||
return Client(
|
||||
|
|
@ -34,6 +41,20 @@ def fixture_streamable_http_client() -> Client[StreamableHttpTransport]:
|
|||
)
|
||||
|
||||
|
||||
@pytest.fixture(name="legacy_client")
|
||||
def fixture_legacy_client() -> Client[StreamableHttpTransport]:
|
||||
"""A handshake-pinned client, for capabilities that exist only in that era."""
|
||||
assert FASTMCP_GITHUB_TOKEN is not None
|
||||
|
||||
return Client(
|
||||
StreamableHttpTransport(
|
||||
url=GITHUB_REMOTE_MCP_URL,
|
||||
auth=BearerAuth(FASTMCP_GITHUB_TOKEN),
|
||||
),
|
||||
mode="legacy",
|
||||
)
|
||||
|
||||
|
||||
class TestGithubMCPRemote:
|
||||
async def test_connect_disconnect(
|
||||
self,
|
||||
|
|
@ -44,11 +65,16 @@ class TestGithubMCPRemote:
|
|||
await streamable_http_client._disconnect() # pylint: disable=W0212 (protected-access)
|
||||
assert streamable_http_client.is_connected() is False
|
||||
|
||||
async def test_ping(self, streamable_http_client: Client[StreamableHttpTransport]):
|
||||
"""Test pinging the server."""
|
||||
async with streamable_http_client:
|
||||
assert streamable_http_client.is_connected() is True
|
||||
result = await streamable_http_client.ping()
|
||||
async def test_ping(self, legacy_client: Client[StreamableHttpTransport]):
|
||||
"""Test pinging the server.
|
||||
|
||||
`ping` is defined only in the handshake era — the modern protocol version
|
||||
does not carry the method at all — so this pins `mode="legacy"` rather
|
||||
than relying on the default negotiation landing there.
|
||||
"""
|
||||
async with legacy_client:
|
||||
assert legacy_client.is_connected() is True
|
||||
result = await legacy_client.ping()
|
||||
assert result is True
|
||||
|
||||
async def test_list_tools(
|
||||
|
|
@ -106,6 +132,10 @@ class TestGithubMCPRemote:
|
|||
"""Test calling a list_commit tool"""
|
||||
async with streamable_http_client:
|
||||
assert streamable_http_client.is_connected()
|
||||
# On a modern connection the client derives `Mcp-Param-*` headers from
|
||||
# the tool's schema, which it only holds once the tool has been listed
|
||||
# in this session. Listing first keeps the call correct in either era.
|
||||
await streamable_http_client.list_tools()
|
||||
result = await streamable_http_client.call_tool(
|
||||
"list_commits", {"owner": "prefecthq", "repo": "fastmcp"}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -1,4 +1,14 @@
|
|||
"""Tests for middleware support during initialization."""
|
||||
"""Tests for middleware support during initialization.
|
||||
|
||||
`on_initialize` only fires for the `initialize` handshake, which is unique to
|
||||
the older protocol version; the modern version connects without it, so a
|
||||
default client never triggers this hook. Most tests below pin `mode="legacy"`
|
||||
for that reason. `test_session_state_persists_across_tool_calls` pins for a
|
||||
different reason: it exercises `ctx.set_state`/`get_state` persisting across
|
||||
multiple tool calls in the same client session, which requires the
|
||||
handshake-era's persistent session (see `test_session_visibility.py` for the
|
||||
same distinction applied to a different feature).
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
from typing import Any
|
||||
|
|
@ -123,7 +133,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 +149,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 +170,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 +200,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 +251,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 +297,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 +325,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 +347,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 +380,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
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ from typing import Any
|
|||
|
||||
import mcp_types
|
||||
import pytest
|
||||
from mcp.shared.dispatcher import CallOptions
|
||||
from mcp.shared.exceptions import MCPError
|
||||
from mcp_types import ElicitRequest, ElicitRequestFormParams, InputRequiredResult
|
||||
|
||||
|
|
@ -65,6 +66,26 @@ def _adder() -> FastMCP:
|
|||
return server
|
||||
|
||||
|
||||
async def _raw_request(
|
||||
client: Client, method: str, params: dict[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
"""Send a bare JSON-RPC request through the dispatcher, bypassing the
|
||||
typed `send_request` that normally stamps the outgoing envelope.
|
||||
|
||||
The modern protocol version requires every request's `params._meta` to
|
||||
carry the protocol version, client info, and client capabilities (there is
|
||||
no handshake to establish them once, up front), so a raw request built by
|
||||
hand must stamp them the same way `send_request` would or the server
|
||||
rejects the envelope before dispatch ever sees it.
|
||||
"""
|
||||
data: dict[str, Any] = {"method": method, "params": params}
|
||||
opts: CallOptions = {}
|
||||
client.session._stamp(data, opts)
|
||||
return await client.session._dispatcher.send_raw_request(
|
||||
method, data.get("params"), opts
|
||||
)
|
||||
|
||||
|
||||
class TestNotificationVisibility:
|
||||
async def test_client_cancelled_notification_reaches_on_message(self):
|
||||
"""A ``notifications/cancelled`` from the client is observed by
|
||||
|
|
@ -118,9 +139,7 @@ class TestUnroutableAndMalformed:
|
|||
|
||||
async with Client(server) as client:
|
||||
with pytest.raises(MCPError):
|
||||
await client.session._dispatcher.send_raw_request(
|
||||
"does/not/exist", {}, {}
|
||||
)
|
||||
await _raw_request(client, "does/not/exist", {})
|
||||
|
||||
assert ("on_message", "does/not/exist") in recorder.records
|
||||
assert ("on_request", "does/not/exist") in recorder.records
|
||||
|
|
@ -135,9 +154,7 @@ class TestUnroutableAndMalformed:
|
|||
|
||||
async with Client(server) as client:
|
||||
with pytest.raises(MCPError):
|
||||
await client.session._dispatcher.send_raw_request(
|
||||
"tools/call", {"not_a_valid": "param"}, {}
|
||||
)
|
||||
await _raw_request(client, "tools/call", {"not_a_valid": "param"})
|
||||
|
||||
assert ("on_message", "tools/call") in recorder.records
|
||||
assert ("on_call_tool", "tools/call") not in recorder.records
|
||||
|
|
@ -216,7 +233,10 @@ class TestMessageModification:
|
|||
server = _adder()
|
||||
server.add_middleware(RewriteLevel())
|
||||
|
||||
async with Client(server) as client:
|
||||
# `logging/setLevel` was dropped from the method registry in the modern
|
||||
# protocol version (logging is opt-in per-request via `_meta` there),
|
||||
# so exercising it needs the older protocol.
|
||||
async with Client(server, mode="legacy") as client:
|
||||
await client.session._dispatcher.send_raw_request(
|
||||
"logging/setLevel", {"level": "not-a-valid-level"}, {}
|
||||
)
|
||||
|
|
@ -227,7 +247,9 @@ class TestMessageModification:
|
|||
recorder = HookRecorder()
|
||||
server.add_middleware(recorder)
|
||||
|
||||
async with Client(server) as client:
|
||||
# `logging/setLevel` only exists on the older protocol; see the pin
|
||||
# note in `test_modified_message_reaches_sdk_dispatch` above.
|
||||
async with Client(server, mode="legacy") as client:
|
||||
await client.session._dispatcher.send_raw_request(
|
||||
"logging/setLevel", {"level": "debug"}, {}
|
||||
)
|
||||
|
|
@ -254,7 +276,9 @@ class TestMessageModification:
|
|||
server.add_middleware(recorder)
|
||||
server.add_middleware(RewriteMethod())
|
||||
|
||||
async with Client(server) as client:
|
||||
# `ping` was removed from the modern protocol version, so this pins
|
||||
# the era where it's still a real method to rewrite away from.
|
||||
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
|
||||
|
|
@ -286,9 +310,7 @@ class TestMessageModification:
|
|||
|
||||
async with Client(server) as client:
|
||||
with pytest.raises(MCPError):
|
||||
await client.session._dispatcher.send_raw_request(
|
||||
"tools/call", {"not_a_valid": "param"}, {}
|
||||
)
|
||||
await _raw_request(client, "tools/call", {"not_a_valid": "param"})
|
||||
|
||||
calls = [r for r in recorder.records if r == ("on_message", "tools/call")]
|
||||
assert len(calls) == 1
|
||||
|
|
|
|||
|
|
@ -176,7 +176,10 @@ class TestMiddlewareHooks:
|
|||
async with Client(mcp_server) as client:
|
||||
await client.call_tool("add", {"a": 1, "b": 2})
|
||||
|
||||
assert recording_middleware.assert_called(at_least=9)
|
||||
# The floor is lower than a legacy connection's 11: the modern
|
||||
# `server/discover` negotiation fires 2 generic hooks, vs. 5 for the
|
||||
# older `initialize` request plus its `notifications/initialized`.
|
||||
assert recording_middleware.assert_called(at_least=8)
|
||||
assert recording_middleware.assert_called(method="tools/call", at_least=3)
|
||||
assert recording_middleware.assert_called(hook="on_message", at_least=1)
|
||||
assert recording_middleware.assert_called(hook="on_request", at_least=1)
|
||||
|
|
@ -299,7 +302,8 @@ class TestMiddlewareHooks:
|
|||
async def test_initialize(
|
||||
self, mcp_server: FastMCP, recording_middleware: RecordingMiddleware
|
||||
):
|
||||
async with Client(mcp_server) as client:
|
||||
# `ping` only exists on the older protocol, so this pins that era.
|
||||
async with Client(mcp_server, mode="legacy") as client:
|
||||
await client.ping()
|
||||
|
||||
assert recording_middleware.assert_called(at_least=1)
|
||||
|
|
|
|||
|
|
@ -193,7 +193,14 @@ class TestPingMiddlewareIntegration:
|
|||
|
||||
assert len(middleware._active_sessions) == 0
|
||||
|
||||
async with Client(mcp) as client:
|
||||
# PingMiddleware keys its keepalive loop off the connection, which
|
||||
# persists for the life of a handshake-era session. On the modern
|
||||
# protocol version, a connection lives only for the single request
|
||||
# that built it, so `_active_sessions` never holds a mid-session
|
||||
# entry an outside observer can see — the register-and-clean-up
|
||||
# happens entirely within one call. That per-request lifecycle is
|
||||
# itself the reason this test pins the older era.
|
||||
async with Client(mcp, mode="legacy") as client:
|
||||
result = await client.call_tool("hello")
|
||||
assert result.content[0].text == "Hello!"
|
||||
|
||||
|
|
@ -214,7 +221,10 @@ class TestPingMiddlewareIntegration:
|
|||
def hello() -> str:
|
||||
return "Hello!"
|
||||
|
||||
async with Client(mcp) as client:
|
||||
# See the pin note in `test_ping_middleware_registers_session`: a
|
||||
# mid-session `_active_sessions` entry is only observable when the
|
||||
# connection persists across requests, which is handshake-era only.
|
||||
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")
|
||||
|
|
@ -88,6 +125,23 @@ def fastmcp_server():
|
|||
async def proxy_server(fastmcp_server: FastMCP):
|
||||
"""
|
||||
A proxy server that forwards interactions with the proxy client to the given fastmcp server.
|
||||
|
||||
`ProxyClient(fastmcp_server)` defaults to `mode="legacy"` (see
|
||||
`TestProxyClientEraDefault` above — a directly-constructed `ProxyClient`
|
||||
always pins the handshake era, independent of `create_proxy`'s era
|
||||
mirroring). Every test below that forwards a tool call through this
|
||||
fixture (not just a listing) needs its front `Client` pinned to
|
||||
`mode="legacy"` too, for either or both of two reasons:
|
||||
|
||||
- The test's subject is itself a handshake-only feature (roots / sampling
|
||||
/ elicitation push, logging, progress): the modern era has no
|
||||
back-channel for server-initiated requests at all, so these forwarding
|
||||
paths cannot exist there.
|
||||
- Even for subjects that work on both eras, a modern front's request
|
||||
`_meta` carries reserved modern-envelope keys that `ProxyTool.run`'s
|
||||
legacy-backend path forwards verbatim onto this legacy-locked backend
|
||||
session, which the backend server then rejects as a protocol
|
||||
violation.
|
||||
"""
|
||||
return create_proxy(ProxyClient(fastmcp_server))
|
||||
|
||||
|
|
@ -106,7 +160,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 +175,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 +184,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 +217,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 +229,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 +257,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 +275,7 @@ class TestProxyClient:
|
|||
|
||||
async with Client(
|
||||
proxy_server,
|
||||
mode="legacy",
|
||||
elicitation_handler=elicitation_handler,
|
||||
) as client:
|
||||
result = await client.call_tool("elicit", {})
|
||||
|
|
@ -233,7 +292,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 +310,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 +338,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 +356,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 +399,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 +463,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"
|
||||
|
|
@ -476,6 +543,12 @@ class TestProxyServerInitiatedForwardingNonTool:
|
|||
Before the fix, only ProxyTool.run stashed the proxy's request context, so
|
||||
resources/templates/prompts forwarded the request into the backend's own
|
||||
context and deadlocked.
|
||||
|
||||
Every test here pins the front to `mode="legacy"`: `roots/list` is a
|
||||
server-initiated request over the handshake's back-channel, which the
|
||||
modern (2026-07-28) era removes entirely — a modern front raises "this
|
||||
transport context has no back-channel for server-initiated requests"
|
||||
rather than reaching the roots handler at all.
|
||||
"""
|
||||
|
||||
async def test_proxied_resource_forwards_list_roots(
|
||||
|
|
@ -488,7 +561,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 +579,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 +597,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
|
||||
|
|
|
|||
|
|
@ -165,7 +165,20 @@ def fastmcp_server():
|
|||
|
||||
@pytest.fixture
|
||||
async def proxy_server(fastmcp_server):
|
||||
"""Fixture that creates a FastMCP proxy server."""
|
||||
"""Fixture that creates a FastMCP proxy server.
|
||||
|
||||
Passing an already-constructed `ProxyClient` as the target (rather than a
|
||||
raw `FastMCP`/URL/etc.) means `create_proxy` reuses that client as-is
|
||||
instead of building one through the era-mirroring factory — so this
|
||||
backend stays pinned to `ProxyClient`'s own default of `mode="legacy"`
|
||||
regardless of what era the front client negotiates. A test that actually
|
||||
forwards a tool *call* through this fixture (not just a listing) needs
|
||||
its own front `Client` pinned to `mode="legacy"` too: otherwise a modern
|
||||
front's request `_meta` carries the reserved modern-envelope keys, which
|
||||
`ProxyTool.run`'s legacy-backend path forwards verbatim onto this
|
||||
legacy-locked backend session, and the backend server rejects it as a
|
||||
protocol violation.
|
||||
"""
|
||||
return create_proxy(ProxyClient(transport=FastMCPTransport(fastmcp_server)))
|
||||
|
||||
|
||||
|
|
@ -196,13 +209,18 @@ async def test_create_proxy_with_transport(fastmcp_server):
|
|||
|
||||
|
||||
async def test_proxy_forwards_upstream_instructions():
|
||||
"""A proxy should surface the upstream server's instructions in the handshake."""
|
||||
"""A proxy should surface the upstream server's instructions in the handshake.
|
||||
|
||||
`FastMCPProxy` registers a `server/discover` handler that forwards the
|
||||
upstream's instructions, mirroring what `ProxyInitializeMiddleware.on_initialize`
|
||||
already does for the legacy handshake, so `client.session.instructions`
|
||||
(era-neutral) resolves the same way on both protocol eras.
|
||||
"""
|
||||
upstream = FastMCP(name="upstream", instructions="USE_THIS_MARKER_123")
|
||||
proxy = create_proxy(upstream, name="proxy")
|
||||
|
||||
async with Client(proxy) as client:
|
||||
assert client.initialize_result is not None
|
||||
assert client.initialize_result.instructions == "USE_THIS_MARKER_123"
|
||||
assert client.session.instructions == "USE_THIS_MARKER_123"
|
||||
|
||||
|
||||
async def test_proxy_own_instructions_take_precedence():
|
||||
|
|
@ -211,8 +229,7 @@ async def test_proxy_own_instructions_take_precedence():
|
|||
proxy = create_proxy(upstream, name="proxy", instructions="proxy instructions")
|
||||
|
||||
async with Client(proxy) as client:
|
||||
assert client.initialize_result is not None
|
||||
assert client.initialize_result.instructions == "proxy instructions"
|
||||
assert client.session.instructions == "proxy instructions"
|
||||
|
||||
|
||||
async def test_proxy_instructions_none_when_upstream_has_none():
|
||||
|
|
@ -221,8 +238,7 @@ async def test_proxy_instructions_none_when_upstream_has_none():
|
|||
proxy = create_proxy(upstream, name="proxy")
|
||||
|
||||
async with Client(proxy) as client:
|
||||
assert client.initialize_result is not None
|
||||
assert client.initialize_result.instructions is None
|
||||
assert client.session.instructions is None
|
||||
|
||||
|
||||
def test_create_proxy_with_url():
|
||||
|
|
@ -254,7 +270,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
|
||||
|
||||
|
||||
|
|
@ -263,10 +279,19 @@ async def test_proxy_ping_surfaces_wrong_remote_path():
|
|||
async with run_server_async(remote, transport="http") as url:
|
||||
proxy = create_proxy(StreamableHttpTransport(url.removesuffix("/mcp")))
|
||||
|
||||
# This asserts the error surfaces from merely *connecting* to the proxy,
|
||||
# with no operation performed. That only happens on the legacy handshake:
|
||||
# `ProxyInitializeMiddleware.on_initialize` eagerly probes the backend
|
||||
# during the front's own `initialize` call. A modern front negotiates
|
||||
# `server/discover` instead, which never runs that middleware hook, so
|
||||
# connecting succeeds regardless of backend health and the failure would
|
||||
# only surface on first real use. Pinned because the subject here is
|
||||
# that eager, handshake-time probe.
|
||||
#
|
||||
# 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
|
||||
|
||||
|
||||
|
|
@ -277,8 +302,11 @@ async def test_proxy_initialize_forwards_remote_connection_error():
|
|||
provider_error_strategy="raise",
|
||||
)
|
||||
|
||||
# Same reasoning as test_proxy_ping_surfaces_wrong_remote_path above: the
|
||||
# error surfaces from connecting alone only via the legacy handshake's
|
||||
# eager backend probe in `ProxyInitializeMiddleware.on_initialize`.
|
||||
with pytest.raises(MCPError, match="Client failed to connect"):
|
||||
async with Client(proxy):
|
||||
async with Client(proxy, mode="legacy"):
|
||||
pass
|
||||
|
||||
|
||||
|
|
@ -301,6 +329,14 @@ async def test_proxy_list_tools_surfaces_remote_connection_error():
|
|||
|
||||
|
||||
async def test_proxy_list_tools_client_surfaces_remote_connection_error():
|
||||
"""With a modern front, connecting succeeds (no eager backend probe — see
|
||||
test_proxy_ping_surfaces_wrong_remote_path) and the failure only surfaces
|
||||
once `list_tools()` actually hits the dead backend. `ProxyProvider._list_tools`
|
||||
now normalizes the raw `httpx2.ConnectError` from the failed backend connect
|
||||
into the `MCPError("Client failed to connect...")` this test expects, the
|
||||
same way `ProxyInitializeMiddleware.on_initialize` and `ProxyTool.run`
|
||||
already did.
|
||||
"""
|
||||
port = find_available_port()
|
||||
proxy = create_proxy(
|
||||
StreamableHttpTransport(f"http://127.0.0.1:{port}/mcp"),
|
||||
|
|
@ -384,22 +420,26 @@ class TestTools:
|
|||
async def test_call_tool_result_same_as_original(
|
||||
self, fastmcp_server: FastMCP, proxy_server: FastMCPProxy
|
||||
):
|
||||
# proxy_server's backend is pinned to legacy (see its fixture docstring);
|
||||
# match the front so a real tool call doesn't cross eras.
|
||||
async with Client(fastmcp_server) 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:
|
||||
# See proxy_server fixture docstring: its backend is pinned to legacy.
|
||||
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):
|
||||
# See proxy_server fixture docstring: its backend is pinned to legacy.
|
||||
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):
|
||||
|
|
@ -465,7 +505,8 @@ class TestTools:
|
|||
meta={"custom_key": "custom_value", "processed": True},
|
||||
)
|
||||
|
||||
async with Client(proxy_server) as client:
|
||||
# See proxy_server fixture docstring: its backend is pinned to legacy.
|
||||
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)
|
||||
|
|
@ -1197,7 +1238,15 @@ 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)
|
||||
# This proxy's backend is built via `ProxyProvider(lambda: ProxyClient(...))`
|
||||
# directly rather than through `create_proxy`'s era-mirroring factory, so it
|
||||
# stays pinned to `ProxyClient`'s own default of `mode="legacy"` regardless
|
||||
# of the front era (see the `proxy_server` fixture docstring above for the
|
||||
# full explanation). Pin the end client to match: a modern front's request
|
||||
# `_meta` carries reserved modern-envelope keys that `ProxyTool.run`'s
|
||||
# legacy-backend path forwards verbatim, and this legacy-locked backend
|
||||
# session rejects them as a protocol violation.
|
||||
client = Client(server, mode="legacy")
|
||||
client._transport_options = TransportOptions(
|
||||
session_class=_ForwardingClientSession
|
||||
)
|
||||
|
|
@ -1239,7 +1288,9 @@ class TestProxyOutputSchemaEnforcement:
|
|||
ProxyProvider(lambda: ProxyClient(backend_violating_its_schema))
|
||||
)
|
||||
|
||||
async with Client(proxy) as client:
|
||||
# `ProxyClient(backend_violating_its_schema)` above is pinned to legacy
|
||||
# (see `_call_without_validating`'s comment); match the front here too.
|
||||
async with Client(proxy, mode="legacy") as client:
|
||||
with pytest.raises(RuntimeError, match="Invalid structured content"):
|
||||
await client.call_tool_mcp("undeclared_status", {})
|
||||
|
||||
|
|
@ -1284,7 +1335,9 @@ class TestProxyOutputSchemaEnforcement:
|
|||
proxy = FastMCP("Proxy")
|
||||
proxy.add_provider(ProxyProvider(lambda: ProxyClient(backend)))
|
||||
|
||||
async with Client(proxy) as client:
|
||||
# `ProxyClient(backend)` above is pinned to legacy (see
|
||||
# `_call_without_validating`'s comment); match the front here too.
|
||||
async with Client(proxy, mode="legacy") as client:
|
||||
await client.call_tool("echo", {"n": 1})
|
||||
lists_after_first = counts["list"]
|
||||
|
||||
|
|
@ -1358,8 +1411,19 @@ 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 plain Client or single-server
|
||||
# config connects the backend directly, so it mirrors the front's auto
|
||||
# era. A multi-server config instead mounts a router with a
|
||||
# StatefulProxyClient per configured server leg — an already-constructed
|
||||
# ProxyClient subclass, same as the `proxy_server` fixture above, pinned
|
||||
# to `mode="legacy"` regardless of the front. Callers with that backend
|
||||
# shape must pin the end client to legacy too, for the reason explained
|
||||
# there (a modern front's request `_meta` gets forwarded verbatim onto a
|
||||
# legacy-locked backend session and rejected as a protocol violation).
|
||||
client = Client(server, mode=mode)
|
||||
client._transport_options = TransportOptions(
|
||||
session_class=_ForwardingClientSession
|
||||
)
|
||||
|
|
@ -1390,7 +1454,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"}
|
||||
|
|
|
|||
|
|
@ -58,6 +58,16 @@ def fastmcp_server():
|
|||
|
||||
@pytest.fixture
|
||||
async def stateful_proxy_server(fastmcp_server: FastMCP):
|
||||
# `StatefulProxyClient` is a `ProxyClient` subclass, so it inherits the same
|
||||
# `mode="legacy"` default for a directly-constructed instance (see
|
||||
# `TestProxyClientEraDefault` in test_proxy_client.py) — this backend isn't
|
||||
# built through `create_proxy`'s era-mirroring factory, so it stays pinned
|
||||
# regardless of the front era. Every test below that forwards a real tool
|
||||
# call through this fixture pins its front `Client` to `mode="legacy"` too:
|
||||
# otherwise a modern front's request `_meta` carries reserved
|
||||
# modern-envelope keys that `ProxyTool.run`'s legacy-backend path forwards
|
||||
# verbatim, and this legacy-locked backend session rejects them as a
|
||||
# protocol violation.
|
||||
client = StatefulProxyClient(transport=FastMCPTransport(fastmcp_server))
|
||||
return FastMCPProxy(client_factory=client.new_stateful)
|
||||
|
||||
|
|
@ -95,8 +105,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 +129,8 @@ 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:
|
||||
# See stateful_proxy_server fixture: its backend is pinned to legacy.
|
||||
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 +141,8 @@ 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:
|
||||
# See stateful_proxy_server fixture: its backend is pinned to legacy.
|
||||
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 +170,9 @@ 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:
|
||||
# Both mounted backends are directly-constructed StatefulProxyClients
|
||||
# (see stateful_proxy_server fixture note above), pinned to legacy.
|
||||
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"
|
||||
|
|
@ -199,10 +217,13 @@ class TestStatefulProxyClient:
|
|||
return ElicitResult(action="accept", content=response_type(name="Alice"))
|
||||
|
||||
# Run the proxy over HTTP so the transport uses
|
||||
# related_request_id routing for server-initiated messages.
|
||||
# related_request_id routing for server-initiated messages. Elicitation
|
||||
# is a handshake-only back-channel feature, and the backend is a
|
||||
# directly-constructed StatefulProxyClient pinned to legacy regardless
|
||||
# (see stateful_proxy_server fixture note above) — pin the front to match.
|
||||
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.01)
|
||||
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.
|
||||
"""
|
||||
|
||||
|
|
@ -54,6 +54,7 @@ class TestNotificationIntegration:
|
|||
|
||||
async with Client(
|
||||
mcp,
|
||||
mode="legacy",
|
||||
elicitation_handler=elicitation_handler,
|
||||
) as client:
|
||||
task = await client.call_tool("elicit_tool", {}, task=True)
|
||||
|
|
@ -108,7 +109,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
|
||||
|
||||
|
|
@ -87,7 +87,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
|
||||
|
|
@ -142,7 +142,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()
|
||||
|
||||
|
|
|
|||
|
|
@ -44,7 +44,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)
|
||||
|
||||
|
|
@ -63,7 +63,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()
|
||||
|
|
@ -73,7 +73,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
|
||||
|
|
@ -91,7 +91,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)
|
||||
|
|
@ -104,7 +104,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")
|
||||
|
|
@ -112,7 +112,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
|
||||
|
|
@ -131,7 +131,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):
|
||||
|
|
@ -152,7 +152,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"):
|
||||
|
|
@ -161,7 +161,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)
|
||||
|
||||
|
|
@ -215,7 +215,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
|
||||
|
|
|
|||
|
|
@ -110,7 +110,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)
|
||||
|
||||
|
|
@ -121,7 +121,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
|
||||
|
|
@ -131,7 +131,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()
|
||||
|
|
@ -139,7 +139,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.05}, task=True
|
||||
)
|
||||
|
|
@ -158,7 +158,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
|
||||
)
|
||||
|
|
@ -188,7 +188,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"},
|
||||
|
|
@ -204,7 +204,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
|
||||
|
|
@ -226,7 +226,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)
|
||||
|
||||
|
|
@ -241,7 +241,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
|
||||
|
|
@ -259,7 +259,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
|
||||
)
|
||||
|
|
@ -270,7 +270,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
|
||||
)
|
||||
|
|
@ -285,7 +285,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)
|
||||
|
||||
|
|
@ -301,14 +301,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()
|
||||
|
|
@ -323,7 +323,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
|
||||
|
|
@ -349,7 +349,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()
|
||||
|
||||
|
|
@ -370,7 +370,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()
|
||||
|
||||
|
|
@ -394,7 +394,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()
|
||||
|
||||
|
|
@ -417,7 +417,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()
|
||||
|
||||
|
|
@ -441,7 +441,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()
|
||||
|
||||
|
|
@ -470,7 +470,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
|
||||
|
|
@ -503,7 +503,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)
|
||||
|
|
@ -531,7 +531,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()
|
||||
|
|
@ -550,7 +550,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()
|
||||
|
|
@ -562,7 +562,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(
|
||||
|
|
@ -659,13 +659,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()
|
||||
|
|
@ -673,7 +673,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()
|
||||
|
|
@ -683,7 +683,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", {})
|
||||
|
||||
|
|
@ -691,13 +691,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
|
||||
)
|
||||
|
|
@ -801,7 +801,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
|
||||
|
|
@ -845,7 +845,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"
|
||||
|
|
@ -888,7 +888,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!"
|
||||
|
|
@ -931,7 +931,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"
|
||||
|
|
@ -980,7 +980,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)
|
||||
|
||||
|
|
@ -1004,7 +1004,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)
|
||||
|
||||
|
|
@ -1028,7 +1028,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)
|
||||
|
||||
|
|
@ -1055,7 +1055,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)
|
||||
|
||||
|
|
@ -1082,7 +1082,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)
|
||||
|
||||
|
|
@ -1106,7 +1106,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)
|
||||
|
||||
|
|
@ -1133,6 +1133,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)
|
||||
|
|
|
|||
|
|
@ -58,7 +58,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)
|
||||
|
||||
|
|
@ -72,7 +72,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)
|
||||
|
|
@ -94,7 +94,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
|
||||
|
|
@ -107,7 +107,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
|
||||
|
|
@ -120,7 +120,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
|
||||
|
|
@ -135,7 +135,8 @@ 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:
|
||||
# Task submission is a handshake-era capability, so this pins the legacy era.
|
||||
async with Client(notification_server, mode="legacy") as client:
|
||||
task = await client.call_tool("slow_task", {}, task=True)
|
||||
task_id = task.task_id
|
||||
# Disconnect before task completes (session __aexit__ cancels subscriptions)
|
||||
|
|
@ -148,7 +149,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
|
||||
|
|
|
|||
|
|
@ -35,7 +35,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",
|
||||
|
|
@ -58,7 +58,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",
|
||||
|
|
@ -80,7 +80,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)
|
||||
|
|
|
|||
|
|
@ -4,6 +4,10 @@ Regression focus: the `sampling create_message` span is created with
|
|||
`record_exception=False, set_status_on_exception=False` and records the
|
||||
exception manually in its `except` block. A failed sampling call must
|
||||
therefore produce exactly ONE exception event, not two.
|
||||
|
||||
`ctx.sample` requires the server to send a request down to the client, which
|
||||
only the older protocol's back-channel supports, so every client below pins
|
||||
`mode="legacy"`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
@ -181,7 +185,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 +226,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 +347,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 +475,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 +508,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 +661,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 +774,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,9 @@ class TestSeamServerSpan:
|
|||
):
|
||||
mcp = FastMCP("test-server")
|
||||
|
||||
async with Client(mcp) as client:
|
||||
# `logging/setLevel` was dropped from the modern protocol version
|
||||
# (SEP-2577), so exercising it needs the older protocol.
|
||||
async with Client(mcp, mode="legacy") as client:
|
||||
await client.set_logging_level("info")
|
||||
|
||||
spans = trace_exporter.get_finished_spans()
|
||||
|
|
@ -470,7 +472,9 @@ class TestSeamServerSpan:
|
|||
"""A seam-spanned method must produce exactly one SERVER span, not two."""
|
||||
mcp = FastMCP("test-server")
|
||||
|
||||
async with Client(mcp) as client:
|
||||
# `logging/setLevel` only exists on the older protocol; see the pin
|
||||
# note in `test_set_logging_level_emits_seam_span` above.
|
||||
async with Client(mcp, mode="legacy") as client:
|
||||
await client.set_logging_level("info")
|
||||
|
||||
spans = trace_exporter.get_finished_spans()
|
||||
|
|
@ -745,10 +749,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()
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ class TestServerIcons:
|
|||
|
||||
# Verify that icons and website_url are passed to the underlying server
|
||||
async with Client(mcp) as client:
|
||||
server_info = client.initialize_result.server_info
|
||||
server_info = client.session.server_info
|
||||
assert server_info.website_url == "https://example.com"
|
||||
assert server_info.icons == icons
|
||||
|
||||
|
|
@ -46,7 +46,7 @@ class TestServerIcons:
|
|||
mcp = FastMCP(name="TestServer")
|
||||
|
||||
async with Client(mcp) as client:
|
||||
server_info = client.initialize_result.server_info
|
||||
server_info = client.session.server_info
|
||||
assert server_info.website_url is None
|
||||
assert server_info.icons is None
|
||||
|
||||
|
|
@ -291,7 +291,7 @@ class TestIconTypes:
|
|||
mcp = FastMCP("TestServer", icons=icons)
|
||||
|
||||
async with Client(mcp) as client:
|
||||
server_info = client.initialize_result.server_info
|
||||
server_info = client.session.server_info
|
||||
assert len(server_info.icons) == 3
|
||||
assert server_info.icons == icons
|
||||
|
||||
|
|
@ -320,7 +320,7 @@ class TestIconTypes:
|
|||
mcp = FastMCP("TestServer", icons=icons)
|
||||
|
||||
async with Client(mcp) as client:
|
||||
server_info = client.initialize_result.server_info
|
||||
server_info = client.session.server_info
|
||||
assert server_info.icons[0].src == "https://example.com/icon.png"
|
||||
assert server_info.icons[0].mime_type is None
|
||||
assert server_info.icons[0].sizes is None
|
||||
|
|
@ -337,7 +337,7 @@ class TestIconTheme:
|
|||
mcp = FastMCP("TestServer", icons=icons)
|
||||
|
||||
async with Client(mcp) as client:
|
||||
server_info = client.initialize_result.server_info
|
||||
server_info = client.session.server_info
|
||||
assert server_info.icons[0].theme == theme
|
||||
|
||||
async def test_icon_without_theme_is_none(self):
|
||||
|
|
@ -347,7 +347,7 @@ class TestIconTheme:
|
|||
mcp = FastMCP("TestServer", icons=icons)
|
||||
|
||||
async with Client(mcp) as client:
|
||||
server_info = client.initialize_result.server_info
|
||||
server_info = client.session.server_info
|
||||
assert server_info.icons[0].theme is None
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1169,7 +1169,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()
|
||||
|
|
|
|||
|
|
@ -568,7 +568,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"
|
||||
|
||||
|
|
|
|||
|
|
@ -48,7 +48,16 @@ class RecordingMessageHandler(MessageHandler):
|
|||
|
||||
|
||||
class TestSessionVisibility:
|
||||
"""Test session-specific visibility control via Context."""
|
||||
"""Test session-specific visibility control via Context.
|
||||
|
||||
Session-scoped visibility rules are stored under `ctx.session_id`. The
|
||||
modern protocol version is stateless: each request gets a fresh
|
||||
connection identity, so a rule set in one request is gone by the next.
|
||||
Tests that only check state within a single tool call are era-neutral
|
||||
and stay unpinned; tests that activate a rule in one request and observe
|
||||
its effect in a later request are pinned to the handshake era, where the
|
||||
rule's persistence is the very thing under test.
|
||||
"""
|
||||
|
||||
async def test_enable_components_stores_rule_dict(self):
|
||||
"""Test that enable_components stores a rule dict in session state."""
|
||||
|
|
@ -116,7 +125,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 +160,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 +191,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 +229,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 +263,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 +301,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 +327,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 +353,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 +383,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)
|
||||
|
|
@ -537,7 +546,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 +565,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 +599,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 +653,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 +690,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 +728,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 +736,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 +765,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,9 @@ 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:
|
||||
# `execution.task_support` (SEP-1686) is advertised in the handshake-era
|
||||
# tool listing only; the modern listing omits it.
|
||||
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,10 @@ class TestBaseTransformBehavior:
|
|||
await ctx.disable_components(names={"delete_record"})
|
||||
return "disabled"
|
||||
|
||||
async with Client(mcp) as client:
|
||||
# Session visibility rules only persist across requests on the
|
||||
# handshake era (see `test_session_visibility.py`); the modern
|
||||
# protocol version has no session for them to persist in.
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -418,14 +418,14 @@ class TestExtensionAdvertisement:
|
|||
)
|
||||
|
||||
async with Client(server) as client:
|
||||
experimental = client.initialize_result.capabilities.experimental or {}
|
||||
experimental = client.server_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:
|
||||
experimental = client.initialize_result.capabilities.experimental
|
||||
experimental = client.server_capabilities.experimental
|
||||
assert not experimental
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -231,7 +231,8 @@ class TestClientBehaviorCompat:
|
|||
assert result.data == "hi"
|
||||
|
||||
async def test_ping_returns_bool(self, server):
|
||||
client = Client(transport=FastMCPTransport(server))
|
||||
# `ping` only exists on the older protocol, so this pins that era.
|
||||
client = Client(transport=FastMCPTransport(server), mode="legacy")
|
||||
async with client:
|
||||
result = await client.ping()
|
||||
assert result is True
|
||||
|
|
|
|||
|
|
@ -100,6 +100,64 @@ 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_transforming_single_server_wrapper_is_legacy_only(self):
|
||||
"""A single-server config that uses tool transforms or tag filters wraps
|
||||
a legacy-pinned proxy; the wrapper transport must advertise legacy-only
|
||||
so a default `mode="auto"` frontend negotiates the same era as the
|
||||
backend rather than negotiating modern against a legacy upstream."""
|
||||
config = {
|
||||
"mcpServers": {
|
||||
"a": {
|
||||
"url": "https://a.example.com/mcp",
|
||||
"include_tags": ["public"],
|
||||
},
|
||||
},
|
||||
}
|
||||
mcp_config = MCPConfig.from_dict(config)
|
||||
transport = mcp_config.mcpServers["a"].to_transport()
|
||||
assert transport.legacy_only is True
|
||||
|
||||
|
||||
def test_parse_single_stdio_config():
|
||||
config = {
|
||||
"mcpServers": {
|
||||
|
|
|
|||
|
|
@ -197,7 +197,8 @@ class TestRunServerInMemory:
|
|||
async def test_custom_path_is_used(self):
|
||||
async with asgi_server(build_server(), path="/custom") as server:
|
||||
assert server.url.endswith("/custom")
|
||||
async with server.client() as client:
|
||||
# `ping` exists only in the handshake era, so this pins that era.
|
||||
async with server.client(mode="legacy") as client:
|
||||
assert await client.ping() is True
|
||||
|
||||
async def test_server_initiated_request_mid_stream(self):
|
||||
|
|
@ -211,7 +212,11 @@ class TestRunServerInMemory:
|
|||
return {"value": "Alice"}
|
||||
|
||||
async with asgi_server(build_server()) as server:
|
||||
async with server.client(elicitation_handler=elicitation_handler) as client:
|
||||
# Server-initiated elicitation is handshake-era only, and it is the
|
||||
# mid-stream request this test exists to exercise, so pin that era.
|
||||
async with server.client(
|
||||
elicitation_handler=elicitation_handler, mode="legacy"
|
||||
) as client:
|
||||
result = await client.call_tool("elicit_name", {})
|
||||
|
||||
assert result.data == "You said Alice"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue