Remove server-initiated sampling and roots from the server API (#4648)

* Remove server-initiated sampling and roots from the server API

Deletes fastmcp/server/sampling/, Context.sample/sample_step/list_roots, and
FastMCP(sampling_handler=). The proxy's handshake-era relay now reaches the
front session through the SDK directly.

* Update tests for the removed sampling and roots server API

* Era-gate client.set_logging_level on modern connections

* Document that server-initiated sampling and roots are not in FastMCP 4

* Silence ty deprecation diagnostics and drop stale sampling doc mentions

* Baseline tools-call-sampling; fix removal leftovers flagged by ruff

* Document sampling handlers on both protocol routes; qualify log-level override

* Docs: sampling and roots work on modern via the guard pattern

The imperative ctx.sample()/ctx.list_roots() stay removed, but both
capabilities survive as input-required requests, as tests/conformance
exercises on 2026-07-28. Direct LLM calls remain the recommendation for
generation; roots has no round-trip-budget objection.

* Change register: record the guard route for sampling and roots

* Editorial pass on the sampling and roots docs

* Flag the sampling removal at the top of the page

* Restore the version badge and point sampling users at 3.x

* Keep the sampling conformance scenario live; fix roots example URIs

* Upgrade guide: staying on 3.x is an option for sampling servers

* Elicitation: state the era split once, not twice
This commit is contained in:
Jeremiah Lowin 2026-07-27 10:32:31 -04:00 committed by GitHub
commit b1e0586d4e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
46 changed files with 417 additions and 5960 deletions

View file

@ -432,31 +432,44 @@ The push-style Context features that require the server to call back into the cl
| `ctx.info` / logging notifications | Supported | Supported |
| Tools, resources, prompts, completions | Supported | Supported |
| `ctx.elicit` (imperative) | Supported | Not on the back-channel — use [elicitation on the modern protocol](https://gofastmcp.com/servers/elicitation#elicitation-on-the-modern-protocol) |
| `ctx.sample` / `ctx.sample_step` | Supported (deprecated) | Removed — call an LLM server-side |
| `ctx.list_roots` | Supported | Via the [guard pattern](https://gofastmcp.com/servers/elicitation#elicitation-on-the-modern-protocol) |
| `ctx.sample` / `ctx.sample_step` | Not in the API | Not in the API — call an LLM server-side |
| `ctx.list_roots` | Not in the API | Not in the API — take paths as arguments, or use the [guard pattern](https://gofastmcp.com/servers/elicitation#elicitation-on-the-modern-protocol) |
| `client.set_logging_level()` | Supported | Raises — `logging/setLevel` is absent from the era's registry |
| Background tasks (`task=True`) | Runs synchronously — never tasked | Supported via the tasks extension |
Tools that rely on `ctx.elicit` or `ctx.list_roots` continue to work against clients on the session-based eras. On the modern era, elicitation is reachable through the multi-round "guard" pattern instead (a tool returns an `InputRequiredResult`; see the New entry below). Sampling is the exception: it is deprecated on every era and will not return on modern connections (see the Deprecated entry below).
Tools that rely on `ctx.elicit` continue to work against clients on the session-based eras; on the modern era, elicitation is reachable through the multi-round "guard" pattern instead (a tool returns an `InputRequiredResult`; see the New entry below). Sampling and roots have no era row to speak of — they left the server API entirely (see the Removed entry below).
Ordinary `ctx.info` usage emits an SDK-level `MCPDeprecationWarning` ("The logging capability is deprecated as of 2026-07-28 (SEP-2577)"). That warning comes from the SDK, not FastMCP, and is benign — logging keeps working on session-based connections per the matrix. `ctx.sample`/`ctx.sample_step` additionally emit a FastMCP-owned `FastMCPDeprecationWarning` (see below). The upgrade guide calls both out explicitly.
Ordinary `ctx.info` usage emits an SDK-level `MCPDeprecationWarning` ("The logging capability is deprecated as of 2026-07-28 (SEP-2577)"). That warning comes from the SDK, not FastMCP, and is benign — logging *notifications* ride the request's own stream and work on every era, including the modern one. The upgrade guide calls it out explicitly.
Wire interop across the transition is verified: a 3.4.3 client against a v4 server and a v4 client against a 3.4.3 server are bidirectionally clean across 9 operations over HTTP (WS2).
*Verify:* `docs/getting-started/upgrading/from-fastmcp-3.mdx` (the published matrix and SDK-warning note), `tests/server/test_protocol_eras.py`.
### Sampling deprecated, era-gated — Deprecated
### Server-initiated sampling and roots removed from the server API — Breaking
`ctx.sample()` and `ctx.sample_step()` are deprecated and slated for removal in a future FastMCP release. Server-initiated sampling relies on the `createMessage` back-channel that SEP-2577 removed from the wire as of `2026-07-28`, and unlike elicitation it has no multi-round-trip replacement (the agentic loop would exhaust the round-trip budget). Both methods now emit a `FastMCPDeprecationWarning` once per process (gated on `settings.deprecation_warnings`), and on a `2026-07-28` connection they raise a clear `ToolError` before touching the wire. The client-side sampling handler infrastructure (anthropic/openai/google_genai) is retained for future MRTR work and is not deprecated. The migration is to call an LLM directly from your server rather than borrowing the client's model.
FastMCP 4 is a modern MCP toolkit, so the capabilities the modern protocol removed are not in its server-authoring API. `Context.sample()`, `Context.sample_step()`, and `Context.list_roots()` are gone, along with the whole `fastmcp/server/sampling/` package (`SamplingTool`, `SampleStep`, `SamplingResult`, the tool loop, structured-result sampling) and the server-side handler arguments `FastMCP(sampling_handler=..., sampling_handler_behavior=...)`. These were previously deprecated-and-era-gated; they are now absent. Calling them raises `AttributeError`; the constructor kwargs raise a `TypeError` naming SEP-2577 and the migration.
The dead TODO at `server/context.py` (a background-task sampling relay that was never built) is removed: that relay is not being built, so the note is gone rather than left as a promise.
The motivating failure is that the gate had become the default experience. `Client` now defaults to `mode="auto"`, which negotiates `2026-07-28` against a FastMCP server, so an unmodified `ctx.sample()` server failed on an ordinary client connection. Four shipped examples (`examples/sampling/`) were broken by that flip; they are deleted rather than ported, and remain available on `release/3.x`.
*Verify:* `fastmcp_slim/fastmcp/server/context.py` (`_warn_sampling_deprecated`, `_is_modern_protocol`, the `sample`/`sample_step` gates), `docs/servers/sampling.mdx` (deprecation banner), `tests/server/test_protocol_eras.py` (warning + era-gate tests).
Server-initiated sampling and roots are *requests* — the server sends one and blocks for the answer — which needs a back-channel the sessionless protocol does not have. What the protocol removed is the *pushing*, not the asking: both capabilities remain reachable through the guard pattern, where a tool returns an `InputRequiredResult` whose `input_requests` map carries a `CreateMessageRequest` or a `ListRootsRequest`, the client answers it, and the tool re-runs and reads `ctx.input_responses`. `Client._drive_input_required()` dispatches those to the same `sampling_handler` / `roots` handler a handshake-era server would have pushed to, and `tests/conformance/server.py` exercises both routes. For roots that guard round is the recommended modern path. For generation it is available but usually the wrong tool — each round is a full request-response cycle, so an agentic loop exhausts the round-trip budget — and the recommended migration stays a direct LLM call from the server.
**What is deliberately kept.** Client-side `Client(sampling_handler=..., roots=...)` and the provider handlers (anthropic/openai/google_genai) stay: a FastMCP client must still answer a legacy server's requests, and removing them would break interop with older servers. `docs/clients/sampling.mdx` and `docs/clients/roots.mdx` stay as real documentation. Logging is untouched — `ctx.log`/`info`/`debug`/`warning`/`error` are notifications that ride the request's own stream and work on every era.
**Proxy relay.** `ProxyClient`'s default `roots` and `sampling_handler` are client-side handlers that relay a handshake-era backend's requests to the proxy's own front client. They are kept, because a proxy is a client to its backend and falls squarely under the interop guarantee above. They no longer route through the removed `Context` methods: both now call the SDK session directly (`ctx.session.list_roots()` / `ctx.session.create_message()`), an internal path with no public authoring surface. The relay is reachable only when both legs speak the handshake era.
*Verify:* `fastmcp_slim/fastmcp/server/context.py` (no `sample`/`sample_step`/`list_roots`), `fastmcp_slim/fastmcp/server/server.py` (`_REMOVED_KWARGS`), `fastmcp_slim/fastmcp/server/providers/proxy.py` (`default_proxy_roots_handler`, `default_proxy_sampling_handler`), `docs/servers/sampling.mdx` (rewritten in place as the explainer), `tests/server/test_protocol_eras.py` (`test_removed_server_initiated_methods_are_absent`), `tests/server/providers/proxy/test_proxy_client.py` (relay still green).
### `client.set_logging_level()` era-gated — Breaking (modern era)
`logging/setLevel` asks a server to remember a level for the rest of the session, and it is absent from the `2026-07-28` method registry because that era has no session to remember it in. It previously surfaced the SDK's opaque "Method not found". `Client.set_logging_level()` now raises a `RuntimeError` naming the era and pointing at level-filtering in the client's `log_handler`; it is unchanged on handshake-era connections. It is never a silent no-op.
*Verify:* `fastmcp_slim/fastmcp/client/client.py` (`set_logging_level`), `tests/server/test_protocol_eras.py` (`test_set_logging_level_is_era_gated_on_modern`).
### Push-feature degradation quality — Resolved (was sdk-feedback #10)
On a `2026-07-28` connection the degradation error used to differ by feature: `ctx.list_roots` raised a clear `NoBackChannelError`, while `ctx.elicit` / `ctx.sample` surfaced a bare "Method not found" because those methods attach a `related_request_id` and reach client dispatch before failing. FastMCP now era-gates `ctx.elicit` and `ctx.sample`/`ctx.sample_step` to raise a clear, era-aware `ToolError` before the wire ("server-initiated sampling is not available on MCP 2026-07-28 connections…" and "elicitation via server-initiated requests is unavailable on 2026-07-28 connections."). The strict xfail that captured #10 is flipped to a passing test.
On a `2026-07-28` connection `ctx.elicit` used to surface a bare "Method not found", because it attaches a `related_request_id` and reaches client dispatch before failing. FastMCP now era-gates `ctx.elicit` to raise a clear, era-aware `ToolError` before the wire ("elicitation via server-initiated requests is unavailable on 2026-07-28 connections."). The strict xfail that captured #10 is flipped to a passing test. The sampling half of #10 is moot: `ctx.sample` no longer exists.
*Verify:* `tests/server/test_protocol_eras.py` (`test_elicit_sample_degradation_message_is_clear_on_modern`, now a real test), `server/context.py` (era gates).
*Verify:* `tests/server/test_protocol_eras.py` (`test_elicit_degradation_message_is_clear_on_modern`, now a real test), `server/context.py` (era gate).
### Server-level cache hints (SEP-2549) — New (opt-in feature)

View file

@ -13,21 +13,15 @@ Code blocks marked as sketches show the *intended* API and do not resolve agains
## Sampling removal
**Status: Deprecation and era-gating shipped (#4448); removal slated for 4.0.**
**Status: Shipped in 4.0.**
Sampling is the push-shaped API where a server borrows the client's model mid-call (`ctx.sample`, `ctx.sample_step`). The `2026-07-28` era removes server-initiated requests, so this API cannot work on modern connections. Background-task sampling is dead under v2 — a worker's back-channel is gone once the submitting request returns, and no sampling relay was ever built (sdk-feedback #9).
Sampling was the push-shaped API where a server borrows the client's model mid-call (`ctx.sample`, `ctx.sample_step`). The `2026-07-28` era removes server-initiated requests, so it cannot work on modern connections, and `Client`'s flip to `mode="auto"` made a modern connection the default — the era gate had become the default experience rather than an edge case. Background-task sampling was dead under v2 in any event: a worker's back-channel is gone once the submitting request returns, and no relay was ever built (sdk-feedback #9).
The plan is Option A: **deprecate the push-sampling API now and remove it in the 4.0 release.** The first two steps shipped in #4448:
Deprecation and era-gating shipped in #4448. The removal completes the plan: `ctx.sample`, `ctx.sample_step`, `ctx.list_roots`, `server/sampling/` (including `SamplingTool` and structured-result sampling), `FastMCP(sampling_handler=..., sampling_handler_behavior=...)`, and `examples/sampling/` are all gone. The server-authoring API is now the modern protocol's API, with nothing in it that only works against old clients.
- **Done:** `ctx.sample` / `ctx.sample_step` emit a `FastMCPDeprecationWarning` (once per process, gated on `settings.deprecation_warnings`).
- **Done:** both are era-gated to raise a clear, era-aware `ToolError` on `2026-07-28` before the wire, which also fixed the opaque "Method not found" of sdk-feedback #10.
- **Pending 4.0:** remove `ctx.sample`, `ctx.sample_step`, `server/sampling/`, `SamplingTool`, and structured-result sampling.
The migration story is honest: there is **no drop-in**. The guidance is architectural — call an LLM from your server directly, with your own API key, rather than borrowing the client's model. For roots, take paths as tool arguments or ask through the guard pattern, whose `input_requests` map still carries a `ListRootsRequest`.
The migration story is honest: there is **no drop-in** on modern connections. The guidance is architectural — call an LLM from your server directly, with your own API key, rather than borrowing the client's model. That shift is the real answer, and it is why the removal justifies a major version.
The client-side provider handlers (Anthropic, OpenAI, Google GenAI) are **retained** regardless: MRTR needs them to answer sampling input-requests from the client side. What is removed is the server-side push emitter, which the SDK never built for the modern era.
Sampling still functions on the legacy eras. Users also see an SDK-level `MCPDeprecationWarning` on ordinary `ctx.sample` usage (the SDK deprecated the capability wire-side per SEP-2577). FastMCP's own deprecation — the warning with migration guidance, plus the era-gating — shipped in #4448; only the final removal remains for 4.0.
The client-side provider handlers (Anthropic, OpenAI, Google GenAI) and `Client(sampling_handler=..., roots=...)` are **retained**: a FastMCP client still has to answer a legacy server's requests, and MRTR needs them from the client side. What is removed is the server-side push emitter. `ProxyClient`'s default relay handlers are retained for the same interop reason and now call the SDK session directly.
## MRTR elicitation

View file

@ -16,7 +16,7 @@ FastMCP v4.0 is an engine swap. Three forces drive the major version:
**Protocol version 2026-07-28.** The SDK v2 serves multiple protocol eras from one server. Alongside the session-based handshake eras, it introduces the sessionless `2026-07-28` era, which discovers capabilities through `server/discover` and removes server-initiated requests (SEP-2577). This formally supersedes FastMCP's earlier "latest protocol only" stance: a single server now works with clients across the protocol transition.
**Sampling removal.** The `2026-07-28` era removes the server's ability to push a request back to the client mid-call. That takes the push-shaped sampling API (`ctx.sample`, `ctx.sample_step`) off the table on modern connections. Rather than leave it half-working, v4 deprecates it now and removes it in the 4.0 release — a real architectural shift for servers that borrowed the client's model, and one that justifies the major bump.
**Sampling and roots removed from the server API.** The `2026-07-28` era removes the server's ability to push a request back to the client mid-call, which takes `ctx.sample`, `ctx.sample_step`, and `ctx.list_roots` off the table. Rather than leave them half-working against old clients only, 4.0 removes them from the server API entirely — a real architectural shift for servers that borrowed the client's model, and one that justifies the major bump. Client-side handlers stay, because a modern client still has to answer a legacy server.
## Release strategy

View file

@ -185,16 +185,11 @@ Set `mode="legacy"` to force the initialize handshake. This behaves identically
client = Client("https://example.com/mcp", mode="legacy")
```
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:
- **[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)
- `client.ping()` and `transport.get_session_id()`
Legacy mode is also what carries the *pushed* form of a server's requests. The handshake opens a persistent back-channel down which a server can send a sampling, roots, or elicitation request mid-call, and the modern era removed it. Your handlers are unaffected by that: a [sampling](/clients/sampling), [roots](/clients/roots), or [elicitation](/clients/elicitation) handler you register answers a modern server's [input-required rounds](/clients/elicitation#input-required-rounds) from the same registration. Pin `mode="legacy"` when you connect to a server that pushes, or when your code calls `client.ping()` or `transport.get_session_id()`, which need the session the modern era does not open.
Conversely, [background tasks](/clients/tasks) are **modern-only**: the tasks capability is negotiated over `2026-07-28` connections, so `mode="legacy"` never triggers one and a task-enabled tool just runs synchronously.
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.
A FastMCP server serves both eras, so a default client negotiates the modern one and the session-dependent calls raise an era-specific error there. Pinning the handshake restores them.
You can also pin a specific modern protocol version to adopt it directly, without a discovery probe:
@ -342,7 +337,7 @@ 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.
Sampling, elicitation, and roots are the requests a server makes of the client. A server reaches your handler by whichever route its [era](#protocol-negotiation) allows — pushed down the open session on the handshake, returned as an input-required result on the modern protocol — and both routes dispatch to the same handler, so one registration covers both. Logging and progress arrive as notifications on the response stream and work in either era.
```python
from fastmcp import Client
@ -360,7 +355,6 @@ 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,

View file

@ -13,10 +13,8 @@ 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).
**These sections show the server-initiated flow, which the handshake-era protocol uses.** On `2026-07-28` the server asks by returning a request instead — see [input-required rounds](#input-required-rounds). One `elicitation_handler` serves both, so the examples below pin `mode="legacy"` only to exercise the pushed form.
</Note>
## Handler Template

View file

@ -1,7 +1,7 @@
---
title: Client Roots
sidebarTitle: Roots
description: Provide local context and resource boundaries to MCP servers.
description: Tell servers which local paths your client can reach.
icon: folder-tree
---
@ -11,29 +11,26 @@ import { VersionBadge } from '/snippets/version-badge.mdx'
Use this when you need to tell servers what local resources the client has access to.
Roots inform servers about resources the client can provide. Servers can use this information to adjust behavior or provide more relevant responses.
A root is a path your client is willing to expose — a project directory, a workspace, a document store. Servers read them to scope their work, so a tool that searches files searches where you pointed it, and a server that gets no roots has to ask the user for paths instead. Roots describe where the client can reach; the server takes them as its working boundary.
<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>
Register them once with `roots=`, and the client answers however the server asks. A handshake-era server pushes a `roots/list` request down the open session and reads the reply mid-call; a modern (`2026-07-28`) server has no such channel, so it returns a roots request and `fastmcp.Client` fulfils it from the same registration and re-issues the call with the answer attached. The default `mode="auto"` negotiates whichever era the server speaks, so the examples below work on either — see [protocol negotiation](/clients/client#protocol-negotiation) for how that choice is made, and [the guard pattern](/servers/elicitation#sampling-and-roots) for how a server issues the modern form.
## Static Roots
Provide a list of roots when creating the client:
When the paths are known up front, pass them as a list. The client holds them for the life of the connection and hands back the same set every time a server asks.
```python
from fastmcp import Client
client = Client(
"my_mcp_server.py",
mode="legacy",
roots=["/path/to/root1", "/path/to/root2"]
roots=["file:///path/to/root1", "file:///path/to/root2"]
)
```
## Dynamic Roots
Use a callback to compute roots dynamically when the server requests them:
Pass a callback instead when the roots depend on something the client learns at runtime, such as the workspace the user has open. It runs at the moment a server asks, on either route, and receives the request context so you can see which request it is answering:
```python
from fastmcp import Client
@ -41,11 +38,10 @@ from fastmcp.client.roots import RequestContext
async def roots_callback(context: RequestContext) -> list[str]:
print(f"Server requested roots (Request ID: {context.request_id})")
return ["/path/to/root1", "/path/to/root2"]
return ["file:///path/to/root1", "file:///path/to/root2"]
client = Client(
"my_mcp_server.py",
mode="legacy",
roots=roots_callback
)
```

View file

@ -1,7 +1,7 @@
---
title: LLM Sampling
sidebarTitle: Sampling
description: Handle server-initiated LLM completion requests.
description: Answer a server's request for an LLM completion.
icon: robot
---
@ -9,57 +9,46 @@ import { VersionBadge } from "/snippets/version-badge.mdx";
<VersionBadge version="2.0.0" />
Use this when you need to respond to server requests for LLM completions.
Use this when a server asks your client to run an LLM completion on its behalf.
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.
Sampling is how a server borrows your model. Rather than hold an API key of its own, the server describes the messages it wants completed and asks you to run them — you pick the model, and you pay for the tokens. Your side of that arrangement is one function, a **sampling handler**, registered when you create the client.
<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>
The handler receives the conversation the server wants completed, the parameters it asked for, and a request context carrying metadata about the call. Return the generated text as a string and FastMCP wraps it in the protocol's result for you; return a `CreateMessageResult` yourself when you want to report the real model name or hand back content that isn't text. If the handler raises, the client sends the error back in place of a completion and the server's tool decides what to do about it.
## Handler Template
```python
from fastmcp import Client
from fastmcp.client.sampling import SamplingMessage, SamplingParams, RequestContext
from mcp_types import TextContent
async def sampling_handler(
messages: list[SamplingMessage],
params: SamplingParams,
context: RequestContext
context: RequestContext,
) -> str:
"""
Handle server requests for LLM completions.
Args:
messages: Conversation messages to send to the LLM
params: Sampling parameters (temperature, max_tokens, etc.)
context: Request context with metadata
Returns:
Generated text response from your LLM
"""
# Extract message content
conversation = []
for message in messages:
content = message.content.text if hasattr(message.content, 'text') else str(message.content)
conversation.append(f"{message.role}: {content}")
# Use the system prompt if provided
"""Run the server's messages against your LLM and return the completion."""
conversation = [
f"{message.role}: {message.content.text}"
for message in messages
if isinstance(message.content, TextContent)
]
system_prompt = params.system_prompt or "You are a helpful assistant."
# Integrate with your LLM service here
# Call your LLM here with `conversation` and `system_prompt`.
return "Generated response based on the messages"
client = Client(
"my_mcp_server.py",
mode="legacy",
sampling_handler=sampling_handler,
)
client = Client("my_mcp_server.py", sampling_handler=sampling_handler)
```
The client answers with this handler however the server asks for a completion. The default `mode="auto"` negotiates whichever protocol era the server speaks, and one handler covers both of the routes an era can use — see [Request Routes](#request-routes).
## Handler Parameters
Everything the server sends arrives in the first two arguments. The messages are the conversation to complete; the parameters are how the server would like it completed. You decide how much of that to honor, since the client owns the model — a preference your provider cannot express is yours to ignore.
<Card icon="code" title="SamplingMessage">
<ResponseField name="role" type='Literal["user", "assistant"]'>
The role of the message
@ -71,11 +60,11 @@ client = Client(
</Card>
<Card icon="code" title="SamplingParams">
<ResponseField name="systemPrompt" type="str | None">
<ResponseField name="system_prompt" type="str | None">
Optional system prompt the server wants to use
</ResponseField>
<ResponseField name="modelPreferences" type="ModelPreferences | None">
<ResponseField name="model_preferences" type="ModelPreferences | None">
Server preferences for model selection (hints, cost/speed/intelligence priorities)
</ResponseField>
@ -83,11 +72,11 @@ client = Client(
Sampling temperature
</ResponseField>
<ResponseField name="maxTokens" type="int">
<ResponseField name="max_tokens" type="int">
Maximum tokens to generate
</ResponseField>
<ResponseField name="stopSequences" type="list[str] | None">
<ResponseField name="stop_sequences" type="list[str] | None">
Stop sequences for sampling
</ResponseField>
@ -95,14 +84,14 @@ client = Client(
Tools the LLM can use during sampling
</ResponseField>
<ResponseField name="toolChoice" type="ToolChoice | None">
<ResponseField name="tool_choice" type="ToolChoice | None">
Tool usage behavior (`auto`, `required`, or `none`)
</ResponseField>
</Card>
## Built-in Handlers
FastMCP provides built-in handlers for OpenAI, Anthropic, and Google Gemini APIs that support the full sampling API including tool use.
Writing the provider call yourself is rarely worth it. FastMCP ships handlers for OpenAI, Anthropic, and Google Gemini that implement the full sampling API, tool use included, and translate the protocol's parameters into each provider's own. Give one a default model and pass it where your own handler would go. Write a custom handler when you need routing across providers, caching, or a provider FastMCP does not cover.
### OpenAI Handler
@ -114,19 +103,19 @@ from fastmcp.client.sampling.handlers.openai import OpenAISamplingHandler
client = Client(
"my_mcp_server.py",
mode="legacy",
sampling_handler=OpenAISamplingHandler(default_model="gpt-4o"),
)
```
For OpenAI-compatible APIs (like local models):
Point the handler at any OpenAI-compatible API, including a local model server, by passing your own provider client:
```python
from fastmcp import Client
from fastmcp.client.sampling.handlers.openai import OpenAISamplingHandler
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"),
@ -148,7 +137,6 @@ 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"),
)
```
@ -167,7 +155,6 @@ 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,25 +163,32 @@ client = Client(
Install the Google Gemini handler with `pip install 'fastmcp[gemini]'`.
</Note>
## Sampling Capabilities
The [source of these handlers](https://github.com/PrefectHQ/fastmcp/tree/main/fastmcp_slim/fastmcp/client/sampling/handlers) is the best reference for writing your own.
When you provide a `sampling_handler`, FastMCP automatically advertises full sampling capabilities to the server, including tool support. To disable tool support for simpler handlers:
## Tool Use
A sampling request can carry tools. When it does, your handler passes them to the model and returns whatever comes back, tool calls included — the server executes the tools itself and sends a follow-up sampling request with the results if it needs another turn. Your handler never runs a tool.
Registering any `sampling_handler` advertises full sampling support, tools included. A handler that only generates text should say so, so servers know not to send tools it will drop:
```python
from fastmcp import Client
from mcp_types import SamplingCapability
async def text_only_handler(messages, params, context) -> str:
return "Generated response based on the messages"
client = Client(
"my_mcp_server.py",
mode="legacy",
sampling_handler=basic_handler,
sampling_capabilities=SamplingCapability(), # No tool support
sampling_handler=text_only_handler,
sampling_capabilities=SamplingCapability(),
)
```
## Tool Execution
## Request Routes
Tool execution happens on the server side. The client's role is to pass tools to the LLM and return the LLM's response (which may include tool use requests). The server then executes the tools and may send follow-up sampling requests with tool results.
Servers reach your handler by two routes, and which one applies depends on the protocol era the connection negotiated. A handshake-era server pushes a `sampling/createMessage` request down the open session while a tool is running and waits for the reply. A modern (`2026-07-28`) connection has no such channel, so the tool ends its round by returning a request for a completion instead; the client answers from your handler and calls the tool again with the result attached.
<Tip>
To implement a custom sampling handler, see the [handler source code](https://github.com/PrefectHQ/fastmcp/tree/main/fastmcp_slim/fastmcp/client/sampling/handlers) as a reference.
</Tip>
One registration covers both, so this is rarely something you configure — it matters only when you pin an era, since `mode="legacy"` is the sole route that carries a pushed request. See [protocol negotiation](/clients/client#protocol-negotiation) for how the era is chosen, and [Sampling](/servers/sampling) under Servers for how a server issues these requests.

View file

@ -698,7 +698,7 @@ When deploying FastMCP behind a load balancer or running multiple server instanc
#### Understanding Sessions
By default, FastMCP's Streamable HTTP transport maintains server-side sessions. Sessions enable stateful MCP features like [elicitation](/servers/elicitation) and [sampling](/servers/sampling), where the server needs to maintain context across multiple requests from the same client.
By default, FastMCP's Streamable HTTP transport maintains server-side sessions. A session holds the context a server keeps across multiple requests from the same client, and it carries the handshake-era back-channel that server-initiated requests like [elicitation](/servers/elicitation) push down.
This works perfectly for single-instance deployments. However, sessions are stored in memory on each server instance, which creates challenges when scaling horizontally.

View file

@ -227,37 +227,40 @@ The camelCase bridge is a migration aid, not a permanent fixture. It works today
## SDK deprecation warnings you may see
Ordinary use of `ctx.info` (client logging) and `ctx.sample` now emits an SDK-level `MCPDeprecationWarning`:
Ordinary use of `ctx.info` (client logging) emits an SDK-level `MCPDeprecationWarning`:
```
The logging/sampling capability is deprecated as of 2026-07-28 (SEP-2577)
The logging capability is deprecated as of 2026-07-28 (SEP-2577)
```
These warnings come from the MCP SDK, not from FastMCP. For logging they are benign: `ctx.info` keeps working on session-based connections exactly as the protocol table below describes, and the SDK is only signaling the protocol's direction. For sampling, FastMCP additionally emits its own `FastMCPDeprecationWarning`: `ctx.sample` and `ctx.sample_step` are deprecated and slated for removal, so treat that warning as a prompt to migrate to server-side LLM calls rather than as informational.
The warning comes from the MCP SDK, not from FastMCP, and it is benign. `ctx.info` and the rest of the logging methods keep working on every era, including the modern one — a log message is a *notification*, which rides the response stream the caller already opened. The SDK is signaling the protocol's direction for the capability declaration, not the notification itself.
## Protocol version support
FastMCP servers built on the SDK v2 serve multiple protocol eras from the same server. The SDK negotiates the era each client speaks: the sessionless `2026-07-28` era (which discovers capabilities through `server/discover`) and earlier session-based handshake versions are all handled simultaneously. This formally supersedes FastMCP's earlier "latest protocol only" stance — a single server now works with clients across the protocol transition.
Not every Context feature is available on every era yet. The imperative push APIs that call back into the client mid-execution — `ctx.elicit`, `ctx.sample`, and `ctx.list_roots` — depend on the session-based back-channel of the earlier eras, so on a `2026-07-28` connection they raise a clear, era-aware error rather than reaching the client. Elicitation itself still reaches the user on the modern era, through the guard pattern: a tool *returns* an `InputRequiredResult` describing what it needs, and the client answers with a fresh call (see [Elicitation on the modern protocol](/servers/elicitation#elicitation-on-the-modern-protocol)). Logging notifications and the request/response features flow on every era.
**`ctx.sample()`, `ctx.sample_step()`, and `ctx.list_roots()` are gone from `Context`**, along with the `sampling_handler=` and `sampling_handler_behavior=` arguments to `FastMCP()`. Touching a removed method raises `AttributeError` on every era, and `FastMCP(sampling_handler=...)` raises a `TypeError` naming the migration, so the break surfaces when you upgrade rather than in production against whichever client happens to negotiate the modern era.
Sampling is the exception that does not come back, and the reason is the protocol rather than an unfinished FastMCP feature. SEP-2577 deprecated server-initiated sampling, so `ctx.sample` and `ctx.sample_step` are **deprecated** and will be removed in a future FastMCP release. Elicitation moved to the guard pattern because the modern protocol still carries elicitation requests; sampling has no equivalent path because the protocol deprecated the pattern itself. The migration is to call an LLM directly from your server rather than borrowing the client's model. See [Sampling](/servers/sampling) for details.
All three *pushed*: the server sent a request down a live back-channel and blocked for the answer, and the sessionless protocol has no such channel. Since `fastmcp.Client` now negotiates the modern protocol by default, a method like that would fail against a default client. What the protocol removed is the pushing, not the asking — sampling, elicitation, and roots all still reach the client through the [guard pattern](/servers/elicitation#elicitation-on-the-modern-protocol), where a tool *returns* an `InputRequiredResult` describing what it needs, the client answers, and it calls again with the answer attached.
Migrating differs by capability. For **roots**, the guard pattern is the direct replacement: a server asks once and has what it needs, so the extra round buys the whole answer, and taking the paths as tool arguments is simpler still when the caller can just supply them. For **sampling**, the guard route works the same way, but generation usually belongs in your server, because every round is a full request-response cycle and a generation loop pays that cost repeatedly. [Call an LLM from your server](/servers/sampling) with your own API key and your tool behaves the same for every client, including the many that never implemented sampling; reach for the guard route when the point is specifically to use the caller's model. If borrowing the caller's model *is* your server — you hold no key of your own, and the token bill was never yours to pay — staying on FastMCP 3.x is the honest answer until that changes.
| Context feature | Earlier eras (session-based) | `2026-07-28` (sessionless) |
| --- | --- | --- |
| `ctx.info` / logging notifications | Supported | Supported |
| Tools, resources, prompts, completions | Supported | Supported |
| `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) |
| `ctx.sample` / `ctx.sample_step` | Method removed — call an LLM server-side | Method removed — call an LLM server-side, or ask via the guard pattern |
| `ctx.list_roots` | Method removed — take paths as tool arguments | Method removed — ask via the guard pattern, or take paths as tool arguments |
| `client.set_logging_level()` | Supported | Raises — `logging/setLevel` needs session state the era lacks |
| `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 |
| Background tasks (`task=True`) | Runs synchronously — never tasked | Supported via the tasks extension |
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).
The client side is unaffected. `sampling_handler=` and `roots=` mean what they always did — see [client sampling](/clients/sampling) and [client roots](/clients/roots) — and one registration serves both routes, since a handshake-era server's pushed request and a modern server's returned one dispatch to the same handler.
## Upgrade checklist
Most servers upgrade untouched. Work down this list to find the ones that don't:
@ -265,11 +268,12 @@ Most servers upgrade untouched. Work down this list to find the ones that don't:
1. **Bump your environment.** Raise any pin below `pydantic>=2.12`; upgrade FastAPI if your resolver complains about Starlette `<1.0.1`.
2. **Fix imports that moved out.** Replace `from mcp.types import X` with `from mcp_types import X`, and update any import from the [removed modules](#moved-imports) (`fastmcp.server.proxy`, `fastmcp.server.openapi`, `fastmcp.server.apps`, the `fastmcp.tools.tool` / `resources.resource` / `prompts.prompt` component shims).
3. **Update removed server APIs.** Swap `as_proxy` → `create_proxy`, `import_server` → `mount`, `mount(prefix=)` → `mount(namespace=)`, and the [other removed methods and keywords](#removed-server-methods-and-mount-keywords).
4. **Update removed tool parameters.** Replace tool `serializer=` (return a `ToolResult`), `exclude_args=` (use `Depends()`), and `StreamableHttpTransport(sse_read_timeout=)`.
5. **Fix `McpError` construction.** Positional `McpError(ErrorData(...))` becomes keyword `McpError(code=..., message=...)`. Catching is unchanged.
6. **Move httpx to httpx2.** Grep for `except httpx.` and for custom `httpx_client_factory` / `httpx.Auth` objects handed to FastMCP, and swap the import to `httpx2`.
7. **Decide the client era.** `Client` now defaults to `mode="auto"`. If a server relies on `on_initialize` or per-session state, keep its clients on `mode="legacy"` or restrict the server's served protocol versions.
8. **Verify behavior changes.** Confirm templated resources that legitimately accept `..` or absolute paths are exempted, and update any client that matched the old `-32002` resource-not-found code.
9. **Run with the camelCase bridge off.** Set `mcp_camelcase_compat = False` (or `FASTMCP_MCP_CAMELCASE_COMPAT=false`) in CI to surface every remaining camelCase read as a hard `AttributeError` before the shims are removed.
4. **Replace `ctx.sample` and `ctx.list_roots`.** Both are gone from `Context`, as are `FastMCP(sampling_handler=...)` and `sampling_handler_behavior=`. Call an LLM directly from your server for generation; ask for roots through the guard pattern, or take file paths as tool arguments. A server whose purpose is to use the caller's model should stay on FastMCP 3.x rather than migrate.
5. **Update removed tool parameters.** Replace tool `serializer=` (return a `ToolResult`), `exclude_args=` (use `Depends()`), and `StreamableHttpTransport(sse_read_timeout=)`.
6. **Fix `McpError` construction.** Positional `McpError(ErrorData(...))` becomes keyword `McpError(code=..., message=...)`. Catching is unchanged.
7. **Move httpx to httpx2.** Grep for `except httpx.` and for custom `httpx_client_factory` / `httpx.Auth` objects handed to FastMCP, and swap the import to `httpx2`.
8. **Decide the client era.** `Client` now defaults to `mode="auto"`. If a server relies on `on_initialize` or per-session state, keep its clients on `mode="legacy"` or restrict the server's served protocol versions.
9. **Verify behavior changes.** Confirm templated resources that legitimately accept `..` or absolute paths are exempted, and update any client that matched the old `-32002` resource-not-found code.
10. **Run with the camelCase bridge off.** Set `mcp_camelcase_compat = False` (or `FASTMCP_MCP_CAMELCASE_COMPAT=false`) in CI to surface every remaining camelCase read as a hard `AttributeError` before the shims are removed.
The executable version of this checklist lives in [`tests/test_upgrade_from_v3.py`](https://github.com/PrefectHQ/fastmcp/blob/main/tests/test_upgrade_from_v3.py): it builds representative 3.x-style servers and asserts they run unchanged, and pins every removed surface to the exact error it now raises.

View file

@ -25,7 +25,9 @@ A FastMCP 4 server answers clients across the protocol transition from one deplo
The same negotiation runs from the client, and its default flipped. A plain `Client(url)` now probes for the modern protocol and adopts it when the server offers it, falling back to the handshake otherwise — where every earlier FastMCP version pinned the handshake outright. That flip is what brings the modern capabilities within reach of ordinary client code: a task-enabled tool hands back a handle to poll, and multi-round-trip elicitation resolves across successive requests, neither requiring the caller to opt in. Set `mode="legacy"` to pin the handshake when you need the session-based back-channel or the classic `initialize` result. See [Protocol negotiation](/clients/client#protocol-negotiation).
The modern protocol is sessionless, so it drops the server's ability to call back into the client mid-request (SEP-2577). Imperative `ctx.elicit` and `ctx.list_roots` move to a request-shaped pattern on modern connections, and server-initiated sampling — which has no such replacement — is [deprecated](/servers/sampling). Everything else about writing a server is unchanged.
The modern protocol is sessionless, so it drops the server's ability to call back into the client mid-request (SEP-2577), and FastMCP 4's server API reflects that. `ctx.elicit` moves to a request-shaped pattern that works on modern connections: the tool returns a description of the input it needs, and the client answers with a fresh call. `ctx.sample`, `ctx.sample_step`, and `ctx.list_roots` are gone from the API, because each of them pushed a request down a live connection and a method that only works against old clients is a trap.
Both capabilities survive in the same request-shaped form. Asking for roots that way is the natural replacement, since one round trip buys the whole answer. Generation usually belongs in the server instead, because a loop of asking rounds spends the round-trip budget over and over — [call an LLM from your server](/servers/sampling). Logging is untouched: `ctx.info` and its siblings are notifications, and notifications ride the response stream on every era. Everything else about writing a server is unchanged.
## State without a session

View file

@ -23,7 +23,7 @@ You can change which `.env` file is loaded by setting the `FASTMCP_ENV_FILE` env
|---|---|---|---|
| `FASTMCP_LOG_LEVEL` | `Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]` | `INFO` | Log level for FastMCP's own logging output. Case-insensitive. |
| `FASTMCP_LOG_ENABLED` | `bool` | `true` | Enable or disable FastMCP logging entirely. |
| `FASTMCP_CLIENT_LOG_LEVEL` | `Literal["debug", "info", "notice", "warning", "error", "critical", "alert", "emergency"]` | None | Default minimum log level for messages sent to MCP clients via `context.log()`. When set, messages below this level are suppressed. Individual clients can override this per-session using the MCP `logging/setLevel` request. |
| `FASTMCP_CLIENT_LOG_LEVEL` | `Literal["debug", "info", "notice", "warning", "error", "critical", "alert", "emergency"]` | None | Default minimum log level for messages sent to MCP clients via `context.log()`. When set, messages below this level are suppressed. Handshake-era clients can override this per-session using the MCP `logging/setLevel` request; the modern protocol has no session to hold that level, so clients on it filter by level in their own log handler instead. |
| `FASTMCP_ENABLE_RICH_LOGGING` | `bool` | `true` | Use rich formatting for log output. Set to `false` for plain Python logging. |
| `FASTMCP_ENABLE_RICH_TRACEBACKS` | `bool` | `true` | Use rich tracebacks for errors. |
| `FASTMCP_DEPRECATION_WARNINGS` | `bool` | `true` | Show deprecation warnings. |

View file

@ -21,7 +21,6 @@ The `Context` object provides a clean interface to access MCP features within yo
- **Progress Reporting**: Update the client on the progress of long-running operations
- **Resource Access**: List and read data from resources registered with the server
- **Prompt Access**: List and retrieve prompts registered with the server
- **LLM Sampling**: Request the client's LLM to generate text based on provided messages
- **User Elicitation**: Request structured input from users during tool execution
- **Request State**: Pass values and non-serializable resources between middleware and handlers within a request (for state that persists across requests, see [Session State](/servers/sessions))
- **Session Visibility**: [Control which components are visible](/servers/visibility#per-session-visibility) to the current session
@ -152,18 +151,9 @@ if result.action == "accept":
See [User Elicitation](/servers/elicitation) for detailed examples and supported response types.
### LLM Sampling
<VersionBadge version="2.0.0" />
Request the client's LLM to generate text based on provided messages, useful for leveraging AI capabilities within your tools.
```python
response = await ctx.sample("Analyze this data", temperature=0.7)
```
See [LLM Sampling](/servers/sampling) for comprehensive usage and advanced techniques.
### Sampling and Roots
Neither capability has a `Context` method. Both used to *push* a request into a live client connection, which the modern MCP protocol has no channel to carry, so a tool now asks for them by returning the request and reading the answer on the next round — the same [guard pattern](/servers/elicitation#sampling-and-roots) elicitation uses on modern connections. That route is the natural one for roots; for generation, [call an LLM directly from your server](/servers/sampling).
### Progress Reporting

View file

@ -596,9 +596,9 @@ The same protocol requirement applies: returning an `InputRequiredResult` from a
### Sampling and roots
Elicitation is the most common request to carry this way, and **roots** requests work identically — the `input_requests` map holds them the same way, and each answer comes back in `ctx.input_responses` under its key (an `ElicitResult` or `ListRootsResult`). See [Client Roots](/clients/roots) for what a roots request contains. `fastmcp.Client` answers both from the handlers you already configured, so a guard tool that mixes them needs no extra client wiring.
Elicitation is the most common request to carry this way, and the map carries the others just as well. A `ListRootsRequest` or a `CreateMessageRequest` sits in `input_requests` exactly as an `ElicitRequest` does, and its answer arrives in `ctx.input_responses` under the same key as a `ListRootsResult` or a `CreateMessageResult`. One map can mix all three, and `fastmcp.Client` answers each from the handlers it already has — `elicitation_handler=`, `roots=`, and `sampling_handler=` — so a tool that asks for a mixture needs no extra client wiring. [Client Roots](/clients/roots) covers what a roots request contains.
The map can structurally hold a **sampling** request too (its answer would be a `CreateMessageResult`), but SEP-2577 deprecated server-initiated sampling on the modern protocol, so reach for a direct server-side LLM call instead of routing generation through a guard round. See [Sampling](/servers/sampling).
Roots and sampling differ in how well they suit the round trip. A server asks for roots once and then has what it needs, so the extra round buys the whole answer. Generation rarely works out that way, because every round is a full request-response cycle and a tool that generates in a loop pays that cost each time — [call an LLM directly from your server](/servers/sampling) unless the point is specifically to use the caller's model.
### Middleware

View file

@ -1,7 +1,7 @@
---
title: Sampling
sidebarTitle: Sampling
description: Request LLM text generation from the client or a configured provider through the MCP context.
description: Generate text from a FastMCP server — by calling an LLM directly, or by asking the client to sample.
icon: robot
---
@ -10,582 +10,102 @@ import { VersionBadge } from "/snippets/version-badge.mdx"
<VersionBadge version="2.0.0" />
<Warning>
**Sampling is deprecated and will be removed in a future FastMCP release.**
**`ctx.sample()` and `ctx.sample_step()` were removed in FastMCP 4.** The modern MCP protocol gives a server no channel to push a request to its client, so there is nothing left for those methods to do.
`ctx.sample()` and `ctx.sample_step()` rely on server-initiated `createMessage`
requests, which MCP removed as of the 2026-07-28 protocol (SEP-2577). They work
only on session-based (handshake-era) connections; on a 2026-07-28 connection
they raise a clear error rather than reaching the client.
**Migration:** call an LLM directly from your server using your own API key and
provider SDK instead of borrowing the client's model. There is no drop-in
replacement on modern connections — this architectural shift is the intended
answer.
To build a server that uses sampling, stay on [FastMCP 3.x](/v3/servers/sampling). On FastMCP 4, generate by [calling an LLM directly](#calling-an-llm-directly), or [ask the caller's model](#asking-the-callers-model) when borrowing their model is the point.
</Warning>
<Note>
This page covers `ctx.sample()`, which requests generation over the handshake-era back-channel. The modern protocol's guard mechanism can structurally carry a sampling request in its `input_requests` map, but SEP-2577 deprecated server-initiated sampling as a pattern rather than only the `ctx.sample()` spelling of it, so that is not a supported migration path. Use the guard mechanism for [elicitation](/servers/elicitation#elicitation-on-the-modern-protocol) and roots, and call an LLM directly from your server for generation.
</Note>
A tool that needs text generated calls a model to get it, and in FastMCP 4 that call is ordinary Python: your server holds an API key, creates a provider client, and awaits a completion inside the tool. No protocol is involved, so the tool behaves the same for every client — including the many that never implemented sampling at all.
LLM sampling allows your MCP tools to request text generation from an LLM during execution. This enables tools to leverage AI capabilities for analysis, generation, reasoning, and more—without the client needing to orchestrate multiple calls.
The alternative is to ask the caller. Sampling borrows *the caller's* model — their provider, their credentials, their bill — by returning a request for a completion that the client fulfils and hands back. Every ask costs a full round trip, so it earns its keep when using the caller's model is the point, and rarely otherwise.
By default, sampling requests are routed to the client's LLM. You can also configure a fallback handler to use a specific provider (like OpenAI) when the client doesn't support sampling, or to always use your own LLM regardless of client capabilities.
## Calling an LLM directly
## Overview
The simplest use of sampling is passing a prompt string to `ctx.sample()`. The method sends the prompt to the LLM, waits for the complete response, and returns a `SamplingResult`. You can access the generated text through the `.text` attribute.
```python
from fastmcp import FastMCP, Context
mcp = FastMCP()
@mcp.tool
async def summarize(content: str, ctx: Context) -> str:
"""Generate a summary of the provided content."""
result = await ctx.sample(f"Please summarize this:\n\n{content}")
return result.text or ""
```
The `SamplingResult` also provides `.result` (identical to `.text` for plain text responses) and `.history` containing the full message exchange—useful if you need to continue the conversation or debug the interaction.
### System Prompts
System prompts let you establish the LLM's role and behavioral guidelines before it processes your request. This is useful for controlling tone, enforcing constraints, or providing context that shouldn't clutter the user-facing prompt.
````python
from fastmcp import FastMCP, Context
mcp = FastMCP()
@mcp.tool
async def generate_code(concept: str, ctx: Context) -> str:
"""Generate a Python code example for a concept."""
result = await ctx.sample(
messages=f"Write a Python example demonstrating '{concept}'.",
system_prompt=(
"You are an expert Python programmer. "
"Provide concise, working code without explanations."
),
temperature=0.7,
max_tokens=300
)
return f"```python\n{result.text}\n```"
````
The `temperature` parameter controls randomness—higher values (up to 1.0) produce more varied outputs, while lower values make responses more deterministic. The `max_tokens` parameter limits response length.
### Model Preferences
Model preferences let you hint at which LLM the client should use for a request. You can pass a single model name or a list of preferences in priority order. These are hints rather than requirements—the actual model used depends on what the client has available.
```python
from fastmcp import FastMCP, Context
mcp = FastMCP()
@mcp.tool
async def technical_analysis(data: str, ctx: Context) -> str:
"""Analyze data using a reasoning-focused model."""
result = await ctx.sample(
messages=f"Analyze this data:\n\n{data}",
model_preferences=["claude-opus-4-5", "gpt-5-2"],
temperature=0.2,
)
return result.text or ""
```
Use model preferences when different tasks benefit from different model characteristics. Creative writing might prefer faster models with higher temperature, while complex analysis might benefit from larger reasoning-focused models.
### Multi-Turn Conversations
For requests that need conversational context, construct a list of `SamplingMessage` objects representing the conversation history. Each message has a `role` ("user" or "assistant") and `content` (a `TextContent` object).
```python
from mcp_types import SamplingMessage, TextContent
from fastmcp import FastMCP, Context
mcp = FastMCP()
@mcp.tool
async def contextual_analysis(query: str, data: str, ctx: Context) -> str:
"""Analyze data with conversational context."""
messages = [
SamplingMessage(
role="user",
content=TextContent(type="text", text=f"Here's my data: {data}"),
),
SamplingMessage(
role="assistant",
content=TextContent(type="text", text="I see the data. What would you like to know?"),
),
SamplingMessage(
role="user",
content=TextContent(type="text", text=query),
),
]
result = await ctx.sample(messages=messages)
return result.text or ""
```
The LLM receives the full conversation thread and responds with awareness of the preceding context.
### Fallback Handlers
Client support for sampling is optional—some clients may not implement it. To ensure your tools work regardless of client capabilities, configure a `sampling_handler` that sends requests directly to an LLM provider.
FastMCP provides built-in handlers for [OpenAI and Anthropic APIs](/clients/sampling#built-in-handlers). These handlers support the full sampling API including tools, automatically converting your Python functions to each provider's format.
<Note>
Install handlers with `pip install 'fastmcp[openai]'` or `pip install 'fastmcp[anthropic]'`.
</Note>
Hold a provider API key in your server's environment, create the client once at module scope so connections are reused across calls, and generate inside the tool. You choose the model, control the prompt, see the token usage, and can test the tool with no client attached.
```python
import anthropic
from fastmcp import FastMCP
from fastmcp.client.sampling.handlers.openai import OpenAISamplingHandler
server = FastMCP(
name="My Server",
sampling_handler=OpenAISamplingHandler(default_model="gpt-4o-mini"),
sampling_handler_behavior="fallback",
)
```
mcp = FastMCP("Summarizer")
llm = anthropic.AsyncAnthropic()
The `sampling_handler_behavior` parameter controls when the handler is used:
- **`"fallback"`** (default): Use the handler only when the client doesn't support sampling. This lets capable clients use their own LLM while ensuring your tools still work with clients that lack sampling support.
- **`"always"`**: Always use the handler, bypassing the client entirely. Use this when you need guaranteed control over which LLM processes requests—for cost control, compliance requirements, or when specific model characteristics are essential.
## Structured Output
<VersionBadge version="2.14.1" />
When you need validated, typed data instead of free-form text, use the `result_type` parameter. FastMCP ensures the LLM returns data matching your type, handling validation and retries automatically.
The `result_type` parameter accepts Pydantic models, dataclasses, and basic types like `int`, `list[str]`, or `dict[str, int]`. When you specify a result type, FastMCP automatically creates a `final_response` tool that the LLM calls to provide its response. If validation fails, the error is sent back to the LLM for retry.
```python
from pydantic import BaseModel
from fastmcp import FastMCP, Context
mcp = FastMCP()
class SentimentResult(BaseModel):
sentiment: str
confidence: float
reasoning: str
@mcp.tool
async def analyze_sentiment(text: str, ctx: Context) -> SentimentResult:
"""Analyze text sentiment with structured output."""
result = await ctx.sample(
messages=f"Analyze the sentiment of: {text}",
result_type=SentimentResult,
async def summarize(text: str) -> str:
"""Summarize a document in two sentences."""
response = await llm.messages.create(
model="claude-sonnet-4-5",
max_tokens=512,
system="Summarize the user's text in exactly two sentences.",
messages=[{"role": "user", "content": text}],
)
return result.result # A validated SentimentResult object
return response.content[0].text
```
When you call this tool, the LLM returns a structured response that FastMCP validates against your Pydantic model. You access the validated object through `result.result`, while `result.text` contains the JSON representation.
Any provider SDK works the same way — swap the client and the call, and the tool signature is unchanged. Because generation is ordinary application code, the concerns around it are ordinary too: retries, timeouts, caching, and cost accounting go wherever you want them rather than being negotiated across a protocol boundary. A tool that chains several generations pays nothing extra for the second and third, where asking the caller would pay a full round trip for each.
### Structured Output with Tools
## Asking the caller's model
Combine structured output with tools for agentic workflows that return validated data. The LLM uses your tools to gather information, then returns a response matching your type.
A tool asks for a completion by returning an `InputRequiredResult` whose `input_requests` map holds a `CreateMessageRequest` under a key you choose. That result completes the round normally. The client runs the completion, then re-issues the same `call_tool` with the answer attached, and your tool reads it from `ctx.input_responses` under the same key — a `CreateMessageResult`. Because the tool runs from the top on every round, the presence of `ctx.input_responses` is what tells the two rounds apart: `None` on the first call, populated on the continuation.
`fastmcp.Client` drives that loop for you and answers from the [`sampling_handler`](/clients/sampling) it already has, so a client written for a handshake-era server needs no extra wiring to satisfy a modern tool that asks this way.
```python
from pydantic import BaseModel
from fastmcp import FastMCP, Context
from fastmcp import Context, FastMCP
from mcp_types import (
CreateMessageRequest,
CreateMessageRequestParams,
CreateMessageResult,
InputRequiredResult,
SamplingMessage,
TextContent,
)
mcp = FastMCP()
mcp = FastMCP("Research")
def search(query: str) -> str:
"""Search the web for information."""
return f"Results for: {query}"
def fetch_url(url: str) -> str:
"""Fetch content from a URL."""
return f"Content from: {url}"
class ResearchResult(BaseModel):
summary: str
sources: list[str]
confidence: float
@mcp.tool
async def research(topic: str, ctx: Context) -> ResearchResult:
"""Research a topic and return structured findings."""
result = await ctx.sample(
messages=f"Research: {topic}",
tools=[search, fetch_url],
result_type=ResearchResult,
)
return result.result
```
<Note>
Structured output with automatic validation only applies to `sample()`. With `sample_step()`, you must manage structured output yourself.
</Note>
## Tool Use
<VersionBadge version="2.14.1" />
Sampling with tools enables agentic workflows where the LLM can call functions to gather information before responding. This implements [SEP-1577](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1577), allowing the LLM to autonomously orchestrate multi-step operations.
Pass Python functions to the `tools` parameter, and FastMCP handles the execution loop automatically—calling tools, returning results to the LLM, and continuing until the LLM provides a final response.
### Defining Tools
Define regular Python functions with type hints and docstrings. FastMCP extracts the function's name, docstring, and parameter types to create tool schemas that the LLM can understand.
```python
from fastmcp import FastMCP, Context
def search(query: str) -> str:
"""Search the web for information."""
return f"Results for: {query}"
def get_time() -> str:
"""Get the current time."""
from datetime import datetime
return datetime.now().strftime("%H:%M:%S")
mcp = FastMCP()
@mcp.tool
async def research(question: str, ctx: Context) -> str:
"""Answer questions using available tools."""
result = await ctx.sample(
messages=question,
tools=[search, get_time],
)
return result.text or ""
```
The LLM sees each function's signature and docstring, using this information to decide when and how to call them. Tool errors are caught and sent back to the LLM, allowing it to recover gracefully. An internal safety limit prevents infinite loops.
### Custom Tool Definitions
For custom names or descriptions, use `SamplingTool.from_function()`:
```python
from fastmcp.server.sampling import SamplingTool
tool = SamplingTool.from_function(
my_func,
name="custom_name",
description="Custom description"
)
result = await ctx.sample(messages="...", tools=[tool])
```
### Error Handling
By default, when a sampling tool raises an exception, the error message (including details) is sent back to the LLM so it can attempt recovery. To prevent sensitive information from leaking to the LLM, use the `mask_error_details` parameter:
```python
result = await ctx.sample(
messages=question,
tools=[search],
mask_error_details=True, # Generic error messages only
)
```
When `mask_error_details=True`, tool errors become generic messages like `"Error executing tool 'search'"` instead of exposing stack traces or internal details.
To intentionally provide specific error messages to the LLM regardless of masking, raise `ToolError`:
```python
from fastmcp.exceptions import ToolError
def search(query: str) -> str:
"""Search for information."""
if not query.strip():
raise ToolError("Search query cannot be empty")
return f"Results for: {query}"
```
`ToolError` messages always pass through to the LLM, making it the escape hatch for errors you want the LLM to see and handle.
### Concurrent Tool Execution
By default, tools execute sequentially — one at a time, in order. When your tools are independent (no shared state between them), you can execute them in parallel with `tool_concurrency`:
```python
result = await ctx.sample(
messages="Research these three topics",
tools=[search, fetch_url],
tool_concurrency=0, # Unlimited parallel execution
)
```
The `tool_concurrency` parameter controls how many tools run at once:
- **`None`** (default): Sequential execution
- **`0`**: Unlimited parallel execution
- **`N > 0`**: Execute at most N tools concurrently
For tools that must not run concurrently (file writes, shared state mutations, etc.), mark them as `sequential` when creating the `SamplingTool`:
```python
from fastmcp.server.sampling import SamplingTool
db_writer = SamplingTool.from_function(
write_to_db,
sequential=True, # Forces all tools in the batch to run sequentially
)
result = await ctx.sample(
messages="Process this data",
tools=[search, db_writer],
tool_concurrency=0, # Would be parallel, but db_writer forces sequential
)
```
<Note>
When any tool in a batch has `sequential=True`, the entire batch executes sequentially regardless of `tool_concurrency`. This is a conservative guarantee — if one tool needs ordering, all tools in that batch respect it.
</Note>
### Client Requirements
<Note>
Sampling with tools requires the client to advertise the `sampling.tools` capability. FastMCP clients do this automatically. For external clients that don't support tool-enabled sampling, configure a fallback handler with `sampling_handler_behavior="always"`.
</Note>
## Advanced Control
<VersionBadge version="2.14.1" />
While `sample()` handles the tool execution loop automatically, some scenarios require fine-grained control over each step. The `sample_step()` method makes a single LLM call and returns a `SampleStep` containing the response and updated history.
Unlike `sample()`, `sample_step()` is stateless—it doesn't remember previous calls. You control the conversation by passing the full message history each time. The returned `step.history` includes all messages up through the current response, making it easy to continue the loop.
Use `sample_step()` when you need to:
- Inspect tool calls before they execute
- Implement custom termination conditions
- Add logging, metrics, or checkpointing between steps
- Build custom agentic loops with domain-specific logic
### Basic Loop
By default, `sample_step()` executes any tool calls and includes the results in the history. Call it in a loop, passing the updated history each time, until a stop condition is met.
```python
from mcp_types import SamplingMessage
from fastmcp import FastMCP, Context
mcp = FastMCP()
def search(query: str) -> str:
return f"Results for: {query}"
def get_time() -> str:
return "12:00 PM"
@mcp.tool
async def controlled_agent(question: str, ctx: Context) -> str:
"""Agent with manual loop control."""
messages: list[str | SamplingMessage] = [question]
while True:
step = await ctx.sample_step(
messages=messages,
tools=[search, get_time],
)
if step.is_tool_use:
# Tools already executed (execute_tools=True by default)
for call in step.tool_calls:
print(f"Called tool: {call.name}")
if not step.is_tool_use:
return step.text or ""
messages = step.history
```
### SampleStep Properties
Each `SampleStep` provides information about what the LLM returned:
| Property | Description |
|----------|-------------|
| `step.is_tool_use` | True if the LLM requested tool calls |
| `step.tool_calls` | List of tool calls requested (if any) |
| `step.text` | The text content (if any) |
| `step.history` | All messages exchanged so far |
The contents of `step.history` depend on `execute_tools`:
- **`execute_tools=True`** (default): Includes tool results, ready for the next iteration
- **`execute_tools=False`**: Includes the assistant's tool request, but you add results yourself
### Manual Tool Execution
Set `execute_tools=False` to handle tool execution yourself. When disabled, `step.history` contains the user message and the assistant's response with tool calls—but no tool results. You execute the tools and append the results as a user message.
```python
from mcp_types import SamplingMessage, ToolResultContent, TextContent
from fastmcp import FastMCP, Context
mcp = FastMCP()
@mcp.tool
async def research(question: str, ctx: Context) -> str:
"""Research with manual tool handling."""
def search(query: str) -> str:
return f"Results for: {query}"
def get_time() -> str:
return "12:00 PM"
tools = {"search": search, "get_time": get_time}
messages: list[SamplingMessage] = [question]
while True:
step = await ctx.sample_step(
messages=messages,
tools=list(tools.values()),
execute_tools=False,
)
if not step.is_tool_use:
return step.text or ""
# Execute tools and collect results
tool_results = []
for call in step.tool_calls:
fn = tools[call.name]
result = fn(**call.input)
tool_results.append(
ToolResultContent(
type="tool_result",
tool_use_id=call.id,
content=[TextContent(type="text", text=result)],
async def ask_the_caller(question: str, ctx: Context) -> str | InputRequiredResult:
"""Put a question to the caller's model and report what it answered."""
responses = ctx.input_responses
if responses is None:
return InputRequiredResult(
result_type="input_required",
input_requests={
"answer": CreateMessageRequest(
method="sampling/createMessage",
params=CreateMessageRequestParams(
messages=[
SamplingMessage(
role="user",
content=TextContent(type="text", text=question),
)
],
max_tokens=100,
),
)
)
},
)
messages = list(step.history)
messages.append(SamplingMessage(role="user", content=tool_results))
answer = responses["answer"]
if isinstance(answer, CreateMessageResult) and isinstance(
answer.content, TextContent
):
return answer.content.text
return "The client returned no completion."
```
To report an error to the LLM, set `is_error=True` on the tool result:
Returning an `InputRequiredResult` needs a `2026-07-28` connection, and FastMCP names the era mismatch if an older client reaches the tool; the conformance suite exercises this route on that version. The map can carry several requests at once and mix kinds — a sampling request beside an elicitation or a roots request — with each answer coming back under its own key. [Elicitation](/servers/elicitation#sampling-and-roots) covers the mechanics of the pattern in full, including how to carry state across rounds.
```python
tool_result = ToolResultContent(
type="tool_result",
tool_use_id=call.id,
content=[TextContent(type="text", text="Permission denied")],
is_error=True,
)
```
## The removed methods
## Method Reference
`Context` has no `sample()` and no `sample_step()`; touching either raises `AttributeError` on every protocol era, rather than failing at runtime only against modern clients. `FastMCP()` accepts neither `sampling_handler=` nor `sampling_handler_behavior=`, and naming one raises a `TypeError` that points at the migration.
<Card icon="code" title="ctx.sample()">
<ResponseField name="ctx.sample" type="async method">
Request text generation from the LLM, running to completion automatically.
The reason is the distinction MCP draws between telling and asking. A notification is fire-and-forget: the server emits it and moves on, and it travels down the response stream the caller already opened, so nothing has to be held open on the server's behalf. That is why [logging](/servers/logging) is untouched by any of this — `ctx.info()` and its siblings reach the client mid-call on every era. Sampling is the other kind. `sampling/createMessage` goes out and the caller must answer before the tool can continue, which needs a live, addressable connection the server can reach into, and the `2026-07-28` revision removed server-initiated requests ([SEP-2577](https://modelcontextprotocol.io/community/sep-guidelines)) precisely because a stateless protocol has no such thing.
<Expandable title="Parameters">
<ResponseField name="messages" type="str | list[str | SamplingMessage]">
The prompt to send. Can be a simple string or a list of messages for multi-turn conversations.
</ResponseField>
What the protocol removed is the pushing, not the asking, so the capability survives in the shape described above. Keeping `ctx.sample()` alongside it would mean shipping a method whose outcome against a default client — one that negotiates the modern era — is a runtime failure.
<ResponseField name="system_prompt" type="str | None" default="None">
Instructions that establish the LLM's role and behavior.
</ResponseField>
<ResponseField name="temperature" type="float | None" default="None">
Controls randomness (0.0 = deterministic, 1.0 = creative).
</ResponseField>
<ResponseField name="max_tokens" type="int | None" default="512">
Maximum tokens to generate.
</ResponseField>
<ResponseField name="model_preferences" type="str | list[str] | None" default="None">
Hints for which model the client should use.
</ResponseField>
<ResponseField name="tools" type="list[Callable] | None" default="None">
Functions the LLM can call during sampling.
</ResponseField>
<ResponseField name="result_type" type="type[T] | None" default="None">
A type for validated structured output. Supports Pydantic models, dataclasses, and basic types like `int`, `list[str]`, or `dict[str, int]`.
</ResponseField>
<ResponseField name="mask_error_details" type="bool | None" default="None">
If True, mask detailed error messages from tool execution. When None (default), uses the global `settings.mask_error_details` value. Tools can raise `ToolError` to bypass masking and provide specific error messages to the LLM.
</ResponseField>
<ResponseField name="tool_concurrency" type="int | None" default="None">
Controls parallel execution of tools. `None` (default) for sequential, `0` for unlimited parallel, or a positive integer for bounded concurrency. If any tool has `sequential=True`, all tools execute sequentially regardless.
</ResponseField>
</Expandable>
<Expandable title="Response">
<ResponseField name="SamplingResult[T]" type="dataclass">
- `.text`: The raw text response (or JSON for structured output)
- `.result`: The typed result—same as `.text` for plain text, or a validated Pydantic object for structured output
- `.history`: All messages exchanged during sampling
</ResponseField>
</Expandable>
</ResponseField>
</Card>
<Card icon="code" title="ctx.sample_step()">
<ResponseField name="ctx.sample_step" type="async method">
Make a single LLM sampling call. Use this for fine-grained control over the sampling loop.
<Expandable title="Parameters">
<ResponseField name="messages" type="str | list[str | SamplingMessage]">
The prompt or conversation history.
</ResponseField>
<ResponseField name="system_prompt" type="str | None" default="None">
Instructions that establish the LLM's role and behavior.
</ResponseField>
<ResponseField name="temperature" type="float | None" default="None">
Controls randomness (0.0 = deterministic, 1.0 = creative).
</ResponseField>
<ResponseField name="max_tokens" type="int | None" default="512">
Maximum tokens to generate.
</ResponseField>
<ResponseField name="tools" type="list[Callable] | None" default="None">
Functions the LLM can call during sampling.
</ResponseField>
<ResponseField name="tool_choice" type="str | None" default="None">
Controls tool usage: `"auto"`, `"required"`, or `"none"`.
</ResponseField>
<ResponseField name="execute_tools" type="bool" default="True">
If True, execute tool calls and append results to history. If False, return immediately with tool calls available for manual execution.
</ResponseField>
<ResponseField name="mask_error_details" type="bool | None" default="None">
If True, mask detailed error messages from tool execution.
</ResponseField>
<ResponseField name="tool_concurrency" type="int | None" default="None">
Controls parallel execution of tools. `None` (default) for sequential, `0` for unlimited parallel, or a positive integer for bounded concurrency.
</ResponseField>
</Expandable>
<Expandable title="Response">
<ResponseField name="SampleStep" type="dataclass">
- `.response`: The raw LLM response
- `.history`: Messages including input, assistant response, and tool results
- `.is_tool_use`: True if the LLM requested tool execution
- `.tool_calls`: List of tool calls (if any)
- `.text`: The text content (if any)
</ResponseField>
</Expandable>
</ResponseField>
</Card>
<Note>
Servers on FastMCP 3 still have `ctx.sample()` and `ctx.sample_step()`, documented in the [FastMCP 3 sampling guide](/v3/servers/sampling). Nothing changes for them until they upgrade.
</Note>

View file

@ -195,7 +195,7 @@ These parameters tune how the server processes requests and communicates with cl
<ParamField body="client_log_level" type="LoggingLevel | None">
<VersionBadge version="3.2.0" />
Default minimum log level for messages sent to MCP clients via `context.log()`. When set, messages below this level are suppressed. Individual clients can override this per-session using the MCP `logging/setLevel` request. One of `"debug"`, `"info"`, `"notice"`, `"warning"`, `"error"`, `"critical"`, `"alert"`, or `"emergency"`
Default minimum log level for messages sent to MCP clients via `context.log()`. When set, messages below this level are suppressed. Handshake-era clients can override this per-session using the MCP `logging/setLevel` request; the modern protocol has no session to hold that level, so clients on it filter by level in their own log handler instead. One of `"debug"`, `"info"`, `"notice"`, `"warning"`, `"error"`, `"critical"`, `"alert"`, or `"emergency"`
</ParamField>
<ParamField body="dereference_schemas" type="bool" default="True">
@ -211,19 +211,9 @@ These parameters tune how the server processes requests and communicates with cl
</ParamField>
</Card>
### Handlers and Storage
These parameters provide custom handlers for MCP capabilities and persistent storage for session state.
### Storage
<Card>
<ParamField body="sampling_handler" type="SamplingHandler | None">
Custom handler for MCP sampling requests (server-initiated LLM calls). See [Sampling](/servers/sampling) for details
</ParamField>
<ParamField body="sampling_handler_behavior" type='Literal["always", "fallback"] | None' default="fallback">
When `"fallback"`, the sampling handler is used only when no tool-specific handler exists. When `"always"`, this handler is used for all sampling requests
</ParamField>
<ParamField body="session_state_store" type="AsyncKeyValue | None">
Persistent key-value store for session state that survives across requests. Defaults to an in-memory store. Provide a custom implementation for persistence across server restarts
</ParamField>

View file

@ -238,7 +238,7 @@ Custom spans are most useful around work that is expensive or hard to debug:
- External calls such as databases, vector stores, HTTP APIs, or queue operations
- Multi-step tool logic where one stage dominates latency
- Prompt or resource generation that fans out to other systems
- Sampling calls made from inside a tool via `ctx.sample(...)`
- LLM calls a tool makes to a model provider
Avoid wrapping every small helper function or simple in-memory transformation. That usually adds noise without making traces easier to interpret.
@ -286,9 +286,9 @@ async def docs_resource(slug: str) -> str:
return await load_doc(slug)
```
### Sampling calls inside tools
### LLM calls inside tools
If your tool uses `ctx.sample(...)`, keep the LLM work nested under the tool span so traces show both application logic and model latency together.
A tool that [calls an LLM directly](/servers/sampling) should keep the model work nested under the tool span, so traces show application logic and model latency together.
For providers with their own OTEL integrations, prefer enabling that instrumentation rather than manually creating a span around every model call. For example, if you use Google GenAI, `logfire.instrument_google_genai()` will emit child spans with token and request metadata under the active FastMCP tool span.

View file

@ -1056,22 +1056,14 @@ mcp = FastMCP(name="ContextDemo")
async def process_data(data_uri: str, ctx: Context) -> dict:
"""Process data from a resource with progress reporting."""
await ctx.info(f"Processing data from {data_uri}")
# Read a resource
resource = await ctx.read_resource(data_uri)
data = resource[0].content if resource else ""
# Report progress
result = await ctx.read_resource(data_uri)
data = result.contents[0].content if result.contents else ""
await ctx.report_progress(progress=50, total=100)
# Example request to the client's LLM for help
summary = await ctx.sample(f"Summarize this in 10 words: {data[:200]}")
summary = str(data)[:200]
await ctx.report_progress(progress=100, total=100)
return {
"length": len(data),
"summary": summary.text
}
return {"length": len(data), "summary": summary}
```
The Context object provides access to:
@ -1079,7 +1071,6 @@ The Context object provides access to:
- **Logging**: `ctx.debug()`, `ctx.info()`, `ctx.warning()`, `ctx.error()`
- **Progress Reporting**: `ctx.report_progress(progress, total)`
- **Resource Access**: `ctx.read_resource(uri)`
- **LLM Sampling**: `ctx.sample(...)`
- **Request Information**: `ctx.request_id`, `ctx.client_id`
For full documentation on the Context object and all its capabilities, see the [Context documentation](/servers/context).

View file

@ -1,62 +0,0 @@
# Sampling Examples
These examples demonstrate FastMCP's sampling API, which allows server tools to request LLM completions from the client.
## Prerequisites
```bash
pip install 'fastmcp[anthropic]'
export ANTHROPIC_API_KEY=your-key
```
Or run directly with `uv`:
```bash
uv run examples/sampling/text.py
```
## Examples
### Simple Text Sampling (`text.py`)
Basic sampling flow where a server tool requests an LLM completion:
```bash
uv run examples/sampling/text.py
```
### Structured Output (`structured_output.py`)
Uses `result_type` to get validated Pydantic models from the LLM:
```bash
uv run examples/sampling/structured_output.py
```
### Tool Use (`tool_use.py`)
Gives the LLM tools to use during sampling (calculator, time, dice):
```bash
uv run examples/sampling/tool_use.py
```
### Server Fallback (`server_fallback.py`)
Configures a fallback sampling handler on the server, enabling sampling even when clients don't support it:
```bash
uv run examples/sampling/server_fallback.py
```
## Using OpenAI Instead
To use OpenAI instead of Anthropic, change the handler:
```python
from fastmcp.client.sampling.handlers.openai import OpenAISamplingHandler
handler = OpenAISamplingHandler(default_model="gpt-4o-mini")
```
And install with `pip install 'fastmcp[openai]'`.

View file

@ -1,88 +0,0 @@
# /// script
# dependencies = ["anthropic", "fastmcp", "rich"]
# ///
"""
Server-Side Fallback Handler
Demonstrates configuring a sampling handler on the server. This ensures
sampling works even when the client doesn't provide a handler.
The server runs as an HTTP server that can be connected to by any MCP client.
Run:
uv run examples/sampling/server_fallback.py
Then connect with any MCP client (e.g., Claude Desktop) or test with:
curl http://localhost:8000/mcp/
"""
import asyncio
from rich.console import Console
from rich.panel import Panel
from fastmcp import FastMCP
from fastmcp.client.sampling.handlers.anthropic import AnthropicSamplingHandler
from fastmcp.server.context import Context
console = Console()
# Create server with a fallback sampling handler
# This handler is used when the client doesn't support sampling
mcp = FastMCP(
"Server with Fallback Handler",
sampling_handler=AnthropicSamplingHandler(default_model="claude-sonnet-4-5"),
sampling_handler_behavior="fallback", # Use only if client lacks sampling
)
@mcp.tool
async def summarize(text: str, ctx: Context) -> str:
"""Summarize the given text."""
console.print(f"[bold cyan]SERVER[/] Summarizing text ({len(text)} chars)...")
result = await ctx.sample(
messages=f"Summarize this text in 1-2 sentences:\n\n{text}",
system_prompt="You are a concise summarizer.",
max_tokens=150,
)
console.print("[bold cyan]SERVER[/] Summary complete")
return result.text or ""
@mcp.tool
async def translate(text: str, target_language: str, ctx: Context) -> str:
"""Translate text to the target language."""
console.print(f"[bold cyan]SERVER[/] Translating to {target_language}...")
result = await ctx.sample(
messages=f"Translate to {target_language}:\n\n{text}",
system_prompt=f"You are a translator. Output only the {target_language} translation.",
max_tokens=500,
)
console.print("[bold cyan]SERVER[/] Translation complete")
return result.text or ""
async def main():
console.print(
Panel.fit(
"[bold]Server-Side Fallback Handler Demo[/]\n\n"
"This server has a built-in Anthropic handler that activates\n"
"when clients don't provide their own sampling support.",
subtitle="server_fallback.py",
)
)
console.print()
console.print("[bold yellow]Starting HTTP server on http://localhost:8000[/]")
console.print("Connect with an MCP client or press Ctrl+C to stop")
console.print()
await mcp.run_http_async(host="localhost", port=8000)
if __name__ == "__main__":
asyncio.run(main())

View file

@ -1,110 +0,0 @@
# /// script
# dependencies = ["anthropic", "fastmcp", "rich"]
# ///
"""
Structured Output Sampling
Demonstrates using `result_type` to get validated Pydantic models from an LLM.
The server exposes a sentiment analysis tool that returns structured data.
Run:
uv run examples/sampling/structured_output.py
"""
import asyncio
from pydantic import BaseModel
from rich.console import Console
from rich.panel import Panel
from rich.table import Table
from fastmcp import Client, Context, FastMCP
from fastmcp.client.sampling import SamplingMessage, SamplingParams
from fastmcp.client.sampling.handlers.anthropic import AnthropicSamplingHandler
console = Console()
class LoggingAnthropicHandler(AnthropicSamplingHandler):
async def __call__(
self, messages: list[SamplingMessage], params: SamplingParams, context
): # type: ignore[override]
console.print(" [bold blue]SAMPLING[/] Calling Claude API...")
result = await super().__call__(messages, params, context)
console.print(" [bold blue]SAMPLING[/] Response received")
return result
# Define a structured output model
class SentimentAnalysis(BaseModel):
sentiment: str # "positive", "negative", or "neutral"
confidence: float # 0.0 to 1.0
keywords: list[str] # Keywords that influenced the analysis
explanation: str # Brief explanation of the analysis
# Create the MCP server
mcp = FastMCP("Sentiment Analyzer")
@mcp.tool
async def analyze_sentiment(text: str, ctx: Context) -> dict:
"""Analyze the sentiment of the given text."""
console.print(" [bold cyan]SERVER[/] Analyzing sentiment...")
result = await ctx.sample(
messages=f"Analyze the sentiment of this text:\n\n{text}",
system_prompt="You are a sentiment analysis expert. Analyze text carefully.",
result_type=SentimentAnalysis,
)
console.print(" [bold cyan]SERVER[/] Analysis complete")
return result.result.model_dump() # type: ignore[attr-defined]
async def main():
console.print(
Panel.fit("[bold]MCP Sampling Flow Demo[/]", subtitle="structured_output.py")
)
console.print()
handler = LoggingAnthropicHandler(default_model="claude-sonnet-4-5")
async with Client(mcp, sampling_handler=handler) as client:
texts = [
"I absolutely love this product! It exceeded all my expectations.",
"The service was okay, nothing special but got the job done.",
"This is the worst experience I've ever had. Never again.",
]
for text in texts:
console.print(f"[bold green]CLIENT[/] Analyzing: [italic]{text[:50]}...[/]")
console.print()
result = await client.call_tool("analyze_sentiment", {"text": text})
data = result.data
# Display results in a table
table = Table(show_header=False, box=None, padding=(0, 2))
table.add_column(style="bold")
table.add_column()
sentiment_color = {
"positive": "green",
"negative": "red",
"neutral": "yellow",
}.get(
data["sentiment"],
"white", # type: ignore[union-attr]
)
table.add_row("Sentiment", f"[{sentiment_color}]{data['sentiment']}[/]") # type: ignore[index]
table.add_row("Confidence", f"{data['confidence']:.0%}") # type: ignore[index]
table.add_row("Keywords", ", ".join(data["keywords"])) # type: ignore[index]
table.add_row("Explanation", data["explanation"]) # type: ignore[index]
console.print(Panel(table, border_style=sentiment_color))
console.print()
if __name__ == "__main__":
asyncio.run(main())

View file

@ -1,78 +0,0 @@
# /// script
# dependencies = ["anthropic", "fastmcp", "rich"]
# ///
"""
Simple Text Sampling
Demonstrates the basic MCP sampling flow where a server tool requests
an LLM completion from the client.
Run:
uv run examples/sampling/text.py
"""
import asyncio
from rich.console import Console
from rich.panel import Panel
from fastmcp import Client, Context, FastMCP
from fastmcp.client.sampling import SamplingMessage, SamplingParams
from fastmcp.client.sampling.handlers.anthropic import AnthropicSamplingHandler
console = Console()
# Create a wrapper handler that logs when the LLM is called
class LoggingAnthropicHandler(AnthropicSamplingHandler):
async def __call__(
self, messages: list[SamplingMessage], params: SamplingParams, context
): # type: ignore[override]
console.print(" [bold blue]SAMPLING[/] Calling Claude API...")
result = await super().__call__(messages, params, context)
console.print(" [bold blue]SAMPLING[/] Response received")
return result
# Create the MCP server
mcp = FastMCP("Haiku Generator")
@mcp.tool
async def write_haiku(topic: str, ctx: Context) -> str:
"""Write a haiku about any topic."""
console.print(
f" [bold cyan]SERVER[/] Tool 'write_haiku' called with topic: {topic}"
)
result = await ctx.sample(
messages=f"Write a haiku about: {topic}",
system_prompt="You are a poet. Write only the haiku, nothing else.",
max_tokens=100,
)
console.print(" [bold cyan]SERVER[/] Returning haiku to client")
return result.text or ""
async def main():
console.print(Panel.fit("[bold]MCP Sampling Flow Demo[/]", subtitle="text.py"))
console.print()
# Create the sampling handler
handler = LoggingAnthropicHandler(default_model="claude-sonnet-4-5")
# Connect client to server with the sampling handler
async with Client(mcp, sampling_handler=handler) as client:
console.print("[bold green]CLIENT[/] Calling tool 'write_haiku'...")
console.print()
result = await client.call_tool("write_haiku", {"topic": "Python programming"})
console.print()
console.print("[bold green]CLIENT[/] Received result:")
console.print(Panel(result.data, title="Haiku", border_style="green")) # type: ignore[arg-type]
if __name__ == "__main__":
asyncio.run(main())

View file

@ -1,125 +0,0 @@
# /// script
# dependencies = ["anthropic", "fastmcp", "rich"]
# ///
"""
Sampling with Tools
Demonstrates giving an LLM tools to use during sampling. The LLM can call
helper functions to gather information before responding.
Run:
uv run examples/sampling/tool_use.py
"""
import asyncio
import random
from datetime import datetime
from pydantic import BaseModel, Field
from rich.console import Console
from rich.panel import Panel
from fastmcp import Client, Context, FastMCP
from fastmcp.client.sampling import SamplingMessage, SamplingParams
from fastmcp.client.sampling.handlers.anthropic import AnthropicSamplingHandler
console = Console()
class LoggingAnthropicHandler(AnthropicSamplingHandler):
async def __call__(
self, messages: list[SamplingMessage], params: SamplingParams, context
): # type: ignore[override]
console.print(" [bold blue]SAMPLING[/] Calling Claude API...")
result = await super().__call__(messages, params, context)
console.print(" [bold blue]SAMPLING[/] Response received")
return result
# Define tools available to the LLM during sampling
def add(a: float, b: float) -> str:
"""Add two numbers together."""
result = a + b
console.print(f" [bold magenta]TOOL[/] add({a}, {b}) = {result}")
return str(result)
def multiply(a: float, b: float) -> str:
"""Multiply two numbers together."""
result = a * b
console.print(f" [bold magenta]TOOL[/] multiply({a}, {b}) = {result}")
return str(result)
def get_current_time() -> str:
"""Get the current date and time."""
console.print(" [bold magenta]TOOL[/] get_current_time()")
return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
def roll_dice(sides: int = 6) -> str:
"""Roll a die with the specified number of sides."""
result = random.randint(1, sides)
console.print(f" [bold magenta]TOOL[/] roll_dice({sides}) = {result}")
return str(result)
# Structured output for the response
class AssistantResponse(BaseModel):
answer: str = Field(description="The answer to the user's question")
tools_used: list[str] = Field(description="List of tools that were used")
reasoning: str = Field(
description="Brief explanation of how the answer was determined"
)
# Create the MCP server
mcp = FastMCP("Smart Assistant")
@mcp.tool
async def ask_assistant(question: str, ctx: Context) -> dict:
"""Ask the assistant a question. It can use tools to help answer."""
console.print(" [bold cyan]SERVER[/] Processing question...")
result = await ctx.sample(
messages=question,
system_prompt="You are a helpful assistant with access to tools. Use them when needed to answer questions accurately.",
tools=[add, multiply, get_current_time, roll_dice],
result_type=AssistantResponse,
)
console.print(" [bold cyan]SERVER[/] Response ready")
return result.result.model_dump() # type: ignore[attr-defined]
async def main():
console.print(Panel.fit("[bold]MCP Sampling Flow Demo[/]", subtitle="tool_use.py"))
console.print()
handler = LoggingAnthropicHandler(default_model="claude-sonnet-4-5")
async with Client(mcp, sampling_handler=handler) as client:
questions = [
"What is 15 times 7, plus 23?",
"Roll a 20-sided dice for me",
"What time is it right now?",
]
for question in questions:
console.print(f"[bold green]CLIENT[/] Question: {question}")
console.print()
result = await client.call_tool("ask_assistant", {"question": question})
data = result.data
console.print(f"[bold green]CLIENT[/] Answer: {data['answer']}") # type: ignore[index]
console.print(
f" Tools used: {', '.join(data['tools_used']) or 'none'}"
) # type: ignore[index]
console.print(f" Reasoning: {data['reasoning']}") # type: ignore[index]
console.print()
if __name__ == "__main__":
asyncio.run(main())

View file

@ -1351,7 +1351,22 @@ class Client(
)
async def set_logging_level(self, level: mcp_types.LoggingLevel) -> None:
"""Send a logging/setLevel request."""
"""Send a logging/setLevel request.
Handshake-era servers only. `logging/setLevel` asks the server to
remember a level for the rest of the session, and the 2026-07-28
protocol has no session to remember it in the method is absent from
that era's registry. Log *notifications* are unaffected: they ride the
request's own stream, so a server's `ctx.info()` still reaches you.
Filter by level on the receiving side instead, in your `log_handler`.
"""
if self.protocol_version in MODERN_PROTOCOL_VERSIONS:
raise RuntimeError(
"logging/setLevel is not available on MCP 2026-07-28 "
"connections; the method requires per-session server state that "
"the modern protocol does not have. Filter incoming log "
"messages by level in your log_handler instead."
)
# Deprecated upstream in SDK v2 but deliberately kept per compat directive;
# removed with the multi-round-trip follow-up.
await self._await_with_session_monitoring(

View file

@ -75,7 +75,7 @@ class AnthropicSamplingHandler:
Example:
```python
from anthropic import AsyncAnthropic
from fastmcp import FastMCP
from fastmcp import Client
from fastmcp.client.sampling.handlers.anthropic import AnthropicSamplingHandler
handler = AnthropicSamplingHandler(
@ -83,7 +83,9 @@ class AnthropicSamplingHandler:
client=AsyncAnthropic(),
)
server = FastMCP(sampling_handler=handler)
# Answers a handshake-era server's push request and a modern server's
# input-required round alike.
client = Client("https://example.com/mcp", sampling_handler=handler)
```
"""

View file

@ -60,18 +60,20 @@ class GoogleGenaiSamplingHandler:
Example:
```python
from google.genai import Client
from fastmcp import FastMCP
from google.genai import Client as GoogleGenaiClient
from fastmcp import Client as FastMCPClient
from fastmcp.client.sampling.handlers.google_genai import (
GoogleGenaiSamplingHandler,
)
handler = GoogleGenaiSamplingHandler(
default_model="gemini-2.0-flash",
client=Client(),
client=GoogleGenaiClient(),
)
server = FastMCP(sampling_handler=handler)
# Answers a handshake-era server's push request and a modern server's
# input-required round alike.
client = FastMCPClient("https://example.com/mcp", sampling_handler=handler)
```
"""

View file

@ -3,7 +3,7 @@ from __future__ import annotations
import logging
import warnings
import weakref
from collections.abc import Callable, Generator, Mapping, Sequence
from collections.abc import Callable, Generator, Mapping
from contextlib import contextmanager
from contextvars import ContextVar, Token
from dataclasses import dataclass
@ -16,9 +16,6 @@ from mcp import LoggingLevel, ServerSession
from mcp.server.context import ServerRequestContext
from mcp_types import (
GetPromptResult,
ModelPreferences,
Root,
SamplingMessage,
)
from mcp_types import Prompt as SDKPrompt
from mcp_types import Resource as SDKResource
@ -42,11 +39,6 @@ from fastmcp.server.elicitation import (
parse_elicit_response_type,
)
from fastmcp.server.low_level import client_supports_extension
from fastmcp.server.sampling import SampleStep, SamplingResult, SamplingTool
from fastmcp.server.sampling.run import (
sample_impl,
sample_step_impl,
)
from fastmcp.server.server import FastMCP, StateValue
from fastmcp.server.transforms.visibility import (
Visibility,
@ -79,30 +71,6 @@ _clamp_logger(logger=to_client_logger, max_level="DEBUG")
T = TypeVar("T", default=Any)
ResultT = TypeVar("ResultT", default=str)
# Import ToolChoiceOption from sampling module (after other imports)
from fastmcp.server.sampling.run import ToolChoiceOption # noqa: E402
# Warn-once guard for the sampling deprecation. Server-initiated createMessage
# was removed from MCP as of 2026-07-28 (SEP-2577); the warning fires a single
# time per process to flag that ctx.sample/ctx.sample_step are on their way out.
# A mutable set (mutated in place, never rebound) rather than a `global` boolean
# so the warn-once state is unambiguously read and written from the module.
_sample_deprecation_warned: set[bool] = set()
_SAMPLING_DEPRECATION_MESSAGE = (
"ctx.sample() and ctx.sample_step() are deprecated and will be removed in a "
"future FastMCP release. They rely on server-initiated createMessage "
"requests, which were removed from MCP as of 2026-07-28 (SEP-2577), so they "
"work only on session-based (handshake-era) connections. Call an LLM "
"directly from your server instead."
)
_SAMPLING_MODERN_ERROR = (
"server-initiated sampling is not available on MCP 2026-07-28 connections; "
"SEP-2577 removed it — call an LLM from your server instead."
)
_ELICIT_MODERN_ERROR = (
"elicitation via server-initiated requests is unavailable on 2026-07-28 "
@ -110,22 +78,6 @@ _ELICIT_MODERN_ERROR = (
)
def _warn_sampling_deprecated() -> None:
"""Emit the sampling deprecation warning once per process.
Gated on ``settings.deprecation_warnings`` like every other FastMCP
deprecation; fires a single time (module-level flag) rather than per call.
"""
if _sample_deprecation_warned or not fastmcp.settings.deprecation_warnings:
return
_sample_deprecation_warned.add(True)
warnings.warn(
_SAMPLING_DEPRECATION_MESSAGE,
FastMCPDeprecationWarning,
stacklevel=3,
)
_current_context: ContextVar[Context | None] = ContextVar("context", default=None)
@ -278,9 +230,8 @@ class Context:
def is_background_task(self) -> bool:
"""True when this context is running in a background task (Docket worker).
When True, certain operations like elicit() and sample() will use
task-aware implementations that can pause the task and wait for
client input.
When True, certain operations like elicit() will use task-aware
implementations that can pause the task and wait for client input.
Example:
```python
@ -936,13 +887,6 @@ class Context:
extra=extra,
)
async def list_roots(self) -> list[Root]:
"""List the roots available to the server, as indicated by the client."""
# Deprecated upstream in SDK v2 but deliberately kept per compat directive;
# removed with the multi-round-trip follow-up.
result = await self.session.list_roots() # ty: ignore[deprecated]
return result.roots
async def send_notification(
self, notification: mcp_types.ServerNotification
) -> None:
@ -1008,227 +952,6 @@ class Context:
return False
return rc.protocol_version in MODERN_PROTOCOL_VERSIONS
def _server_can_sample(self) -> bool:
"""True when a server-configured sampling handler can serve the request
without the client back-channel.
FastMCP supports a server-side sampling handler (``FastMCP(sampling_handler=...)``).
With ``sampling_handler_behavior="always"`` the handler always answers;
with ``"fallback"`` it answers whenever the client cannot. On modern
connections the client back-channel is gone, so either configuration lets
the server answer entirely server-side as long as a handler is set. (For
``"always"`` without a handler the sampling implementation raises its own
clear "no handler configured" error, which is not an era concern.)
"""
fastmcp = self.fastmcp
if fastmcp.sampling_handler_behavior == "always":
return True
return fastmcp.sampling_handler is not None
async def sample_step(
self,
messages: str | Sequence[str | SamplingMessage],
*,
system_prompt: str | None = None,
temperature: float | None = None,
max_tokens: int | None = None,
model_preferences: ModelPreferences | str | list[str] | None = None,
tools: Sequence[SamplingTool | Callable[..., Any]] | None = None,
tool_choice: ToolChoiceOption | str | None = None,
execute_tools: bool = True,
mask_error_details: bool | None = None,
tool_concurrency: int | None = None,
) -> SampleStep:
"""
Make a single LLM sampling call.
This is a stateless function that makes exactly one LLM call and optionally
executes any requested tools. Use this for fine-grained control over the
sampling loop.
Args:
messages: The message(s) to send. Can be a string, list of strings,
or list of SamplingMessage objects.
system_prompt: Optional system prompt for the LLM.
temperature: Optional sampling temperature.
max_tokens: Maximum tokens to generate. Defaults to 512.
model_preferences: Optional model preferences.
tools: Optional list of tools the LLM can use.
tool_choice: Tool choice mode ("auto", "required", or "none").
execute_tools: If True (default), execute tool calls and append results
to history. If False, return immediately with tool_calls available
in the step for manual execution.
mask_error_details: If True, mask detailed error messages from tool
execution. When None (default), uses the global settings value.
Tools can raise ToolError to bypass masking.
tool_concurrency: Controls parallel execution of tools:
- None (default): Sequential execution (one at a time)
- 0: Unlimited parallel execution
- N > 0: Execute at most N tools concurrently
If any tool has sequential=True, all tools execute sequentially
regardless of this setting.
Returns:
SampleStep containing:
- .response: The raw LLM response
- .history: Messages including input, assistant response, and tool results
- .is_tool_use: True if the LLM requested tool execution
- .tool_calls: List of tool calls (if any)
- .text: The text content (if any)
Example:
messages = "Research X"
while True:
step = await ctx.sample_step(messages, tools=[search])
if not step.is_tool_use:
print(step.text)
break
# Continue with tool results
messages = step.history
"""
_warn_sampling_deprecated()
# On modern (2026-07-28) connections the client back-channel is gone
# (SEP-2577). A server-configured sampling handler can still answer
# entirely server-side; only raise the era error when nothing can serve
# the request. When modern, force the handler path (never attempt the
# dead client) by passing client_available=False.
client_available = not self._is_modern_protocol()
if not client_available and not self._server_can_sample():
raise ToolError(_SAMPLING_MODERN_ERROR)
return await sample_step_impl(
self,
messages=messages,
system_prompt=system_prompt,
temperature=temperature,
max_tokens=max_tokens,
model_preferences=model_preferences,
tools=tools,
tool_choice=tool_choice,
auto_execute_tools=execute_tools,
mask_error_details=mask_error_details,
tool_concurrency=tool_concurrency,
client_available=client_available,
)
@overload
async def sample(
self,
messages: str | Sequence[str | SamplingMessage],
*,
system_prompt: str | None = None,
temperature: float | None = None,
max_tokens: int | None = None,
model_preferences: ModelPreferences | str | list[str] | None = None,
tools: Sequence[SamplingTool | Callable[..., Any]] | None = None,
result_type: type[ResultT],
mask_error_details: bool | None = None,
tool_concurrency: int | None = None,
) -> SamplingResult[ResultT]:
"""Overload: With result_type, returns SamplingResult[ResultT]."""
@overload
async def sample(
self,
messages: str | Sequence[str | SamplingMessage],
*,
system_prompt: str | None = None,
temperature: float | None = None,
max_tokens: int | None = None,
model_preferences: ModelPreferences | str | list[str] | None = None,
tools: Sequence[SamplingTool | Callable[..., Any]] | None = None,
result_type: None = None,
mask_error_details: bool | None = None,
tool_concurrency: int | None = None,
) -> SamplingResult[str]:
"""Overload: Without result_type, returns SamplingResult[str]."""
async def sample(
self,
messages: str | Sequence[str | SamplingMessage],
*,
system_prompt: str | None = None,
temperature: float | None = None,
max_tokens: int | None = None,
model_preferences: ModelPreferences | str | list[str] | None = None,
tools: Sequence[SamplingTool | Callable[..., Any]] | None = None,
result_type: type[ResultT] | None = None,
mask_error_details: bool | None = None,
tool_concurrency: int | None = None,
) -> SamplingResult[ResultT] | SamplingResult[str]:
"""
Send a sampling request to the client and await the response.
This method runs to completion automatically. When tools are provided,
it executes a tool loop: if the LLM returns a tool use request, the tools
are executed and the results are sent back to the LLM. This continues
until the LLM provides a final text response.
When result_type is specified, a synthetic `final_response` tool is
created. The LLM calls this tool to provide the structured response,
which is validated against the result_type and returned as `.result`.
For fine-grained control over the sampling loop, use sample_step() instead.
Args:
messages: The message(s) to send. Can be a string, list of strings,
or list of SamplingMessage objects.
system_prompt: Optional system prompt for the LLM.
temperature: Optional sampling temperature.
max_tokens: Maximum tokens to generate. Defaults to 512.
model_preferences: Optional model preferences.
tools: Optional list of tools the LLM can use. Accepts plain
functions or SamplingTools.
result_type: Optional type for structured output. When specified,
a synthetic `final_response` tool is created and the LLM's
response is validated against this type.
mask_error_details: If True, mask detailed error messages from tool
execution. When None (default), uses the global settings value.
Tools can raise ToolError to bypass masking.
tool_concurrency: Controls parallel execution of tools:
- None (default): Sequential execution (one at a time)
- 0: Unlimited parallel execution
- N > 0: Execute at most N tools concurrently
If any tool has sequential=True, all tools execute sequentially
regardless of this setting.
Returns:
SamplingResult[T] containing:
- .text: The text representation (raw text or JSON for structured)
- .result: The typed result (str for text, parsed object for structured)
- .history: All messages exchanged during sampling
Deprecated:
Server-initiated sampling relies on the createMessage back-channel,
which MCP removed as of 2026-07-28 (SEP-2577). This method works only
on session-based (handshake-era) connections and will be removed in a
future FastMCP release. Call an LLM directly from your server instead.
"""
_warn_sampling_deprecated()
# On modern (2026-07-28) connections the client back-channel is gone
# (SEP-2577). A server-configured sampling handler can still answer
# entirely server-side; only raise the era error when nothing can serve
# the request. When modern, force the handler path (never attempt the
# dead client) by passing client_available=False.
client_available = not self._is_modern_protocol()
if not client_available and not self._server_can_sample():
raise ToolError(_SAMPLING_MODERN_ERROR)
return await sample_impl( # ty: ignore[invalid-return-type]
self,
messages=messages,
system_prompt=system_prompt,
temperature=temperature,
max_tokens=max_tokens,
model_preferences=model_preferences,
tools=tools,
result_type=result_type,
mask_error_details=mask_error_details,
tool_concurrency=tool_concurrency,
client_available=client_available,
)
@overload
async def elicit(
self,

View file

@ -1282,9 +1282,19 @@ class FastMCPProxy(FastMCP):
async def default_proxy_roots_handler(
context: ServerRequestContext[Any, Any],
) -> RootsList:
"""Forward list roots request from remote server to proxy's connected clients."""
"""Forward list roots request from remote server to proxy's connected clients.
A handshake-era backend can still issue `roots/list`, and the proxy is that
backend's client, so it relays the request onto its own front session. This
reaches the wire through the SDK session rather than a `Context` method:
`ctx.list_roots()` is not part of FastMCP's server-authoring API, because
SEP-2577 removed server-initiated requests from the modern protocol. The
relay exists only for handshake-era interop on both legs.
"""
ctx = get_context()
return await ctx.list_roots()
# Deprecated upstream in SDK v2; the handshake-era relay is the one caller.
result = await ctx.session.list_roots() # ty: ignore[deprecated]
return result.roots
async def default_proxy_sampling_handler(
@ -1292,16 +1302,27 @@ async def default_proxy_sampling_handler(
params: mcp_types.CreateMessageRequestParams,
context: ServerRequestContext[Any, Any],
) -> mcp_types.CreateMessageResult:
"""Forward sampling request from remote server to proxy's connected clients."""
"""Forward sampling request from remote server to proxy's connected clients.
Relays through the SDK session for the same reason as
`default_proxy_roots_handler`: server-initiated sampling is not part of
FastMCP's server-authoring API, and this path only ever runs when both legs
of the proxy speak the handshake era.
"""
ctx = get_context()
result = await ctx.sample(
list(messages),
# Deprecated upstream in SDK v2; the handshake-era relay is the one caller.
result = await ctx.session.create_message( # ty: ignore[deprecated]
messages=list(messages),
system_prompt=params.system_prompt,
temperature=params.temperature,
max_tokens=params.max_tokens,
model_preferences=params.model_preferences,
related_request_id=ctx.origin_request_id,
)
content = mcp_types.TextContent(type="text", text=result.text or "")
text = (
result.content.text if isinstance(result.content, mcp_types.TextContent) else ""
)
content = mcp_types.TextContent(type="text", text=text)
return mcp_types.CreateMessageResult(
role="assistant",
model="fastmcp-client",

View file

@ -1,10 +0,0 @@
"""Sampling module for FastMCP servers."""
from fastmcp.server.sampling.run import SampleStep, SamplingResult
from fastmcp.server.sampling.sampling_tool import SamplingTool
__all__ = [
"SampleStep",
"SamplingResult",
"SamplingTool",
]

View file

@ -1,836 +0,0 @@
"""Sampling types and helper functions for FastMCP servers."""
from __future__ import annotations
import inspect
import json
from collections.abc import Callable, Sequence
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any, Generic, Literal, cast
import anyio
from mcp_types import (
ClientCapabilities,
CreateMessageResult,
CreateMessageResultWithTools,
ModelHint,
ModelPreferences,
SamplingCapability,
SamplingMessage,
SamplingMessageContentBlock,
SamplingToolsCapability,
TextContent,
ToolChoice,
ToolResultContent,
ToolUseContent,
)
from mcp_types import CreateMessageRequestParams as SamplingParams
from mcp_types import Tool as SDKTool
from opentelemetry.trace import SpanKind, Status, StatusCode
from pydantic import ValidationError
from typing_extensions import TypeVar
from fastmcp import settings
from fastmcp.exceptions import ToolError
from fastmcp.server.sampling.sampling_tool import SamplingTool
from fastmcp.telemetry import get_tracer, restore_dropped_attributes
from fastmcp.tools.function_tool import FunctionTool
from fastmcp.tools.tool_transform import TransformedTool
from fastmcp.utilities.async_utils import gather
from fastmcp.utilities.json_schema import compress_schema
from fastmcp.utilities.logging import get_logger
from fastmcp.utilities.types import get_cached_typeadapter
logger = get_logger(__name__)
if TYPE_CHECKING:
from fastmcp.server.context import Context
ResultT = TypeVar("ResultT")
# Maximum number of consecutive final_response validation retries (not
# counting the initial attempt) before aborting. Total attempts = N + 1.
_MAX_VALIDATION_RETRIES = 3
# Simplified tool choice type - just the mode string instead of the full MCP object
ToolChoiceOption = Literal["auto", "required", "none"]
# How many times we retry when the LLM returns text instead of calling final_response
_MAX_TEXT_RESPONSE_RETRIES = 3
@dataclass
class SamplingResult(Generic[ResultT]):
"""Result of a sampling operation.
Attributes:
text: The text representation of the result (raw text or JSON for structured).
result: The typed result (str for text, parsed object for structured output).
history: All messages exchanged during sampling.
"""
text: str | None
result: ResultT
history: list[SamplingMessage]
@dataclass
class SampleStep:
"""Result of a single sampling call.
Represents what the LLM returned in this step plus the message history.
"""
response: CreateMessageResult | CreateMessageResultWithTools
history: list[SamplingMessage]
@property
def is_tool_use(self) -> bool:
"""True if the LLM is requesting tool execution."""
if isinstance(self.response, CreateMessageResultWithTools):
return self.response.stop_reason == "toolUse"
return False
@property
def text(self) -> str | None:
"""Extract text from the response, if available."""
content = self.response.content
if isinstance(content, list):
for block in content:
if isinstance(block, TextContent):
return block.text
return None
elif isinstance(content, TextContent):
return content.text
return None
@property
def tool_calls(self) -> list[ToolUseContent]:
"""Get the list of tool calls from the response."""
content = self.response.content
if isinstance(content, list):
return [c for c in content if isinstance(c, ToolUseContent)]
elif isinstance(content, ToolUseContent):
return [content]
return []
def _parse_model_preferences(
model_preferences: ModelPreferences | str | list[str] | None,
) -> ModelPreferences | None:
"""Convert model preferences to ModelPreferences object."""
if model_preferences is None:
return None
elif isinstance(model_preferences, ModelPreferences):
return model_preferences
elif isinstance(model_preferences, str):
return ModelPreferences(hints=[ModelHint(name=model_preferences)])
elif isinstance(model_preferences, list):
if not all(isinstance(h, str) for h in model_preferences):
raise ValueError("All elements of model_preferences list must be strings.")
return ModelPreferences(hints=[ModelHint(name=h) for h in model_preferences])
else:
raise ValueError(
"model_preferences must be one of: ModelPreferences, str, list[str], or None."
)
# --- Standalone functions for sample_step() ---
def determine_handler_mode(
context: Context, needs_tools: bool, *, client_available: bool = True
) -> bool:
"""Determine whether to use fallback handler or client for sampling.
Args:
context: The MCP context.
needs_tools: Whether the sampling request requires tool support.
client_available: Whether the client back-channel can be reached at all.
On modern (2026-07-28) connections the server-initiated createMessage
back-channel was removed (SEP-2577), so the client can never serve a
sampling request; pass False there to force the server-side handler
path (``"fallback"`` behaves like ``"always"`` when a handler exists).
Returns:
True if fallback handler should be used, False to use client.
Raises:
ValueError: If client lacks required capability and no fallback configured.
"""
fastmcp = context.fastmcp
session = context.session
# Check what capabilities the client has. On connections without a
# back-channel the client can never serve the request regardless of the
# capabilities it advertised, so treat both as unavailable.
has_sampling = client_available and session.check_client_capability(
capability=ClientCapabilities(sampling=SamplingCapability())
)
has_tools_capability = client_available and session.check_client_capability(
capability=ClientCapabilities(
sampling=SamplingCapability(tools=SamplingToolsCapability())
)
)
if fastmcp.sampling_handler_behavior == "always":
if fastmcp.sampling_handler is None:
raise ValueError(
"sampling_handler_behavior is 'always' but no handler configured"
)
return True
elif fastmcp.sampling_handler_behavior == "fallback":
client_sufficient = has_sampling and (not needs_tools or has_tools_capability)
if not client_sufficient:
if fastmcp.sampling_handler is None:
if needs_tools and has_sampling and not has_tools_capability:
raise ValueError(
"Client does not support sampling with tools. "
"The client must advertise the sampling.tools capability."
)
raise ValueError("Client does not support sampling")
return True
elif fastmcp.sampling_handler_behavior is not None:
raise ValueError(
f"Invalid sampling_handler_behavior: {fastmcp.sampling_handler_behavior!r}. "
"Must be 'always', 'fallback', or None."
)
elif not has_sampling:
raise ValueError("Client does not support sampling")
elif needs_tools and not has_tools_capability:
raise ValueError(
"Client does not support sampling with tools. "
"The client must advertise the sampling.tools capability."
)
return False
async def call_sampling_handler(
context: Context,
messages: list[SamplingMessage],
*,
system_prompt: str | None,
temperature: float | None,
max_tokens: int,
model_preferences: ModelPreferences | str | list[str] | None,
sdk_tools: list[SDKTool] | None,
tool_choice: ToolChoice | None,
) -> CreateMessageResult | CreateMessageResultWithTools:
"""Make LLM call using the fallback handler.
Note: This function expects the caller (sample_step) to have validated that
sampling_handler is set via determine_handler_mode(). The checks below are
safeguards against internal misuse.
"""
if context.fastmcp.sampling_handler is None:
raise RuntimeError("sampling_handler is None")
if context.request_context is None:
raise RuntimeError("request_context is None")
result = context.fastmcp.sampling_handler(
messages,
SamplingParams(
system_prompt=system_prompt,
messages=messages,
temperature=temperature,
max_tokens=max_tokens,
model_preferences=_parse_model_preferences(model_preferences),
tools=sdk_tools,
tool_choice=tool_choice,
),
# SamplingHandler is typed against the SDK's RequestContext placeholder,
# but FastMCP hands handlers its own FastMCPRequestContext wrapper at
# runtime; the two aren't structurally related in the type system.
context.request_context, # ty: ignore[invalid-argument-type]
)
if inspect.isawaitable(result):
result = await result
result = cast("str | CreateMessageResult | CreateMessageResultWithTools", result)
# Convert string to CreateMessageResult
if isinstance(result, str):
return CreateMessageResult(
role="assistant",
content=TextContent(type="text", text=result),
model="unknown",
stop_reason="endTurn",
)
return result
async def execute_tools(
tool_calls: list[ToolUseContent],
tool_map: dict[str, SamplingTool],
mask_error_details: bool = False,
tool_concurrency: int | None = None,
) -> list[ToolResultContent]:
"""Execute tool calls and return results.
Args:
tool_calls: List of tool use requests from the LLM.
tool_map: Mapping from tool name to SamplingTool.
mask_error_details: If True, mask detailed error messages from tool execution.
When masked, only generic error messages are returned to the LLM.
Tools can explicitly raise ToolError to bypass masking when they want
to provide specific error messages to the LLM.
tool_concurrency: Controls parallel execution of tools:
- None (default): Sequential execution (one at a time)
- 0: Unlimited parallel execution
- N > 0: Execute at most N tools concurrently
If any tool has sequential=True, all tools execute sequentially
regardless of this setting.
Returns:
List of tool result content blocks in the same order as tool_calls.
"""
if tool_concurrency is not None and tool_concurrency < 0:
raise ValueError(
f"tool_concurrency must be None, 0 (unlimited), or a positive integer, "
f"got {tool_concurrency}"
)
async def _execute_single_tool(tool_use: ToolUseContent) -> ToolResultContent:
"""Execute a single tool and return its result."""
tool = tool_map.get(tool_use.name)
if tool is None:
return ToolResultContent(
type="tool_result",
tool_use_id=tool_use.id,
content=[
TextContent(
type="text",
text=f"Error: Unknown tool '{tool_use.name}'",
)
],
is_error=True,
)
tracer = get_tracer()
span_attrs = {
"gen_ai.tool.name": tool_use.name,
"fastmcp.tool.use_id": tool_use.id,
}
with tracer.start_as_current_span(
f"sampling tool {tool_use.name}",
kind=SpanKind.INTERNAL,
attributes=span_attrs,
) as span:
# Restore: `attributes=span_attrs` above lets on_start hooks and
# the sampler see these values at creation time. But OTel's
# Tracer.start_span builds the span from
# `sampling_result.attributes`, not the `attributes` kwarg
# directly — a custom Sampler whose SamplingResult.attributes
# defaults to None silently drops everything we passed. This
# only fires when the span ends up with no attributes at all, so
# any sampler that supplied attributes of its own — forwarding
# ours, redacting or replacing some, or substituting entirely its
# own — is left untouched, as is an SDK attribute limit that
# evicted some.
if span.is_recording():
restore_dropped_attributes(span, span_attrs)
try:
result_value = await tool.run(tool_use.input)
return ToolResultContent(
type="tool_result",
tool_use_id=tool_use.id,
content=[TextContent(type="text", text=str(result_value))],
)
except ToolError as e:
if span.is_recording():
span.set_attribute("error.type", "tool_error")
span.record_exception(e)
span.set_status(Status(StatusCode.ERROR, str(e)))
logger.log(
e.log_level,
f"Error calling sampling tool '{tool_use.name}'",
exc_info=True,
)
return ToolResultContent(
type="tool_result",
tool_use_id=tool_use.id,
content=[TextContent(type="text", text=str(e))],
is_error=True,
)
except Exception as e:
if span.is_recording():
span.set_attribute("error.type", type(e).__qualname__)
span.record_exception(e)
span.set_status(Status(StatusCode.ERROR, str(e)))
logger.exception(f"Error calling sampling tool '{tool_use.name}'")
if mask_error_details:
error_text = f"Error executing tool '{tool_use.name}'"
else:
error_text = f"Error executing tool '{tool_use.name}': {e}"
return ToolResultContent(
type="tool_result",
tool_use_id=tool_use.id,
content=[TextContent(type="text", text=error_text)],
is_error=True,
)
# Check if any tool requires sequential execution
requires_sequential = any(
tool.sequential
for tool_use in tool_calls
if (tool := tool_map.get(tool_use.name)) is not None
)
# Execute sequentially if required or if concurrency is None (default)
if tool_concurrency is None or requires_sequential:
tool_results: list[ToolResultContent] = []
for tool_use in tool_calls:
result = await _execute_single_tool(tool_use)
tool_results.append(result)
return tool_results
# Execute in parallel
if tool_concurrency == 0:
# Unlimited parallel execution
return await gather(_execute_single_tool(tc) for tc in tool_calls)
else:
# Bounded parallel execution with semaphore
semaphore = anyio.Semaphore(tool_concurrency)
async def bounded_execute(tool_use: ToolUseContent) -> ToolResultContent:
async with semaphore:
return await _execute_single_tool(tool_use)
return await gather(bounded_execute(tc) for tc in tool_calls)
# --- Helper functions for sampling ---
def prepare_messages(
messages: str | Sequence[str | SamplingMessage],
) -> list[SamplingMessage]:
"""Convert various message formats to a list of SamplingMessage objects."""
if isinstance(messages, str):
return [
SamplingMessage(
content=TextContent(text=messages, type="text"), role="user"
)
]
else:
return [
SamplingMessage(content=TextContent(text=m, type="text"), role="user")
if isinstance(m, str)
else m
for m in messages
]
def prepare_tools(
tools: Sequence[SamplingTool | FunctionTool | TransformedTool | Callable[..., Any]]
| None,
) -> list[SamplingTool] | None:
"""Convert tools to SamplingTool objects.
Accepts SamplingTool instances, FunctionTool instances, TransformedTool instances,
or plain callable functions. FunctionTool and TransformedTool are converted using
from_callable_tool(), while plain functions use from_function().
Args:
tools: Sequence of tools to prepare. Can be SamplingTool, FunctionTool,
TransformedTool, or plain callable functions.
Returns:
List of SamplingTool instances, or None if tools is None.
"""
if tools is None:
return None
sampling_tools: list[SamplingTool] = []
for t in tools:
if isinstance(t, SamplingTool):
sampling_tools.append(t)
elif isinstance(t, (FunctionTool, TransformedTool)):
sampling_tools.append(SamplingTool.from_callable_tool(t))
elif callable(t):
sampling_tools.append(SamplingTool.from_function(t))
else:
raise TypeError(
f"Expected SamplingTool, FunctionTool, TransformedTool, or callable, got {type(t)}"
)
return sampling_tools if sampling_tools else None
def extract_tool_calls(
response: CreateMessageResult | CreateMessageResultWithTools,
) -> list[ToolUseContent]:
"""Extract tool calls from a response."""
content = response.content
if isinstance(content, list):
return [c for c in content if isinstance(c, ToolUseContent)]
elif isinstance(content, ToolUseContent):
return [content]
return []
def create_final_response_tool(result_type: type) -> SamplingTool:
"""Create a synthetic 'final_response' tool for structured output.
This tool is used to capture structured responses from the LLM.
The tool's schema is derived from the result_type.
"""
type_adapter = get_cached_typeadapter(result_type)
schema = type_adapter.json_schema()
schema = compress_schema(schema, prune_titles=True)
# Tool parameters must be object-shaped. Wrap primitives in {"value": <schema>}
if schema.get("type") != "object":
schema = {
"type": "object",
"properties": {"value": schema},
"required": ["value"],
}
# The fn just returns the input as-is (validation happens in the loop)
def final_response(**kwargs: Any) -> dict[str, Any]:
return kwargs
return SamplingTool(
name="final_response",
description=(
"Call this tool to provide your final response. "
"Use this when you have completed the task and are ready to return the result."
),
parameters=schema,
fn=final_response,
)
# --- Implementation functions for Context methods ---
async def sample_step_impl(
context: Context,
messages: str | Sequence[str | SamplingMessage],
*,
system_prompt: str | None = None,
temperature: float | None = None,
max_tokens: int | None = None,
model_preferences: ModelPreferences | str | list[str] | None = None,
tools: Sequence[SamplingTool | FunctionTool | TransformedTool | Callable[..., Any]]
| None = None,
tool_choice: ToolChoiceOption | str | None = None,
auto_execute_tools: bool = True,
mask_error_details: bool | None = None,
tool_concurrency: int | None = None,
client_available: bool = True,
) -> SampleStep:
"""Implementation of Context.sample_step().
Make a single LLM sampling call. This is a stateless function that makes
exactly one LLM call and optionally executes any requested tools.
When ``client_available`` is False (e.g. a modern 2026-07-28 connection with
no back-channel), the client is never used and a configured sampling handler
serves the request; the caller is responsible for raising a clear era error
when no handler can serve it.
"""
# Convert messages to SamplingMessage objects
current_messages = prepare_messages(messages)
# Convert tools to SamplingTools
sampling_tools = prepare_tools(tools)
sdk_tools: list[SDKTool] | None = (
[t._to_sdk_tool() for t in sampling_tools] if sampling_tools else None
)
tool_map: dict[str, SamplingTool] = (
{t.name: t for t in sampling_tools} if sampling_tools else {}
)
# Determine whether to use fallback handler or client
use_fallback = determine_handler_mode(
context, bool(sampling_tools), client_available=client_available
)
# Build tool choice
effective_tool_choice: ToolChoice | None = None
if tool_choice is not None:
if tool_choice not in ("auto", "required", "none"):
raise ValueError(
f"Invalid tool_choice: {tool_choice!r}. "
"Must be 'auto', 'required', or 'none'."
)
effective_tool_choice = ToolChoice.model_validate({"mode": tool_choice})
# Effective max_tokens
effective_max_tokens = max_tokens if max_tokens is not None else 512
# Make the LLM call
tracer = get_tracer()
span_attrs = {
"mcp.method.name": "sampling/createMessage",
"fastmcp.server.name": context.fastmcp.name,
}
with tracer.start_as_current_span(
"sampling create_message",
kind=SpanKind.CLIENT,
attributes=span_attrs,
record_exception=False,
set_status_on_exception=False,
) as span:
# Restore: `attributes=span_attrs` above lets on_start hooks and the
# sampler see these values at creation time. But OTel's
# Tracer.start_span builds the span from
# `sampling_result.attributes`, not the `attributes` kwarg directly —
# a custom Sampler whose SamplingResult.attributes defaults to None
# silently drops everything we passed. This only fires when the span
# ends up with no attributes at all, so any sampler that supplied
# attributes of its own — forwarding ours, redacting or replacing
# some, or substituting entirely its own — is left untouched, as is
# an SDK attribute limit that evicted some.
if span.is_recording():
restore_dropped_attributes(span, span_attrs)
try:
if use_fallback:
response = await call_sampling_handler(
context,
current_messages,
system_prompt=system_prompt,
temperature=temperature,
max_tokens=effective_max_tokens,
model_preferences=model_preferences,
sdk_tools=sdk_tools,
tool_choice=effective_tool_choice,
)
else:
# Deprecated upstream in SDK v2 but deliberately kept per compat
# directive; removed with the multi-round-trip follow-up.
response = await context.session.create_message( # ty: ignore[deprecated]
messages=current_messages,
system_prompt=system_prompt,
temperature=temperature,
max_tokens=effective_max_tokens,
model_preferences=_parse_model_preferences(model_preferences),
tools=sdk_tools,
tool_choice=effective_tool_choice,
related_request_id=context.origin_request_id,
)
except Exception as e:
if span.is_recording():
span.set_attribute("error.type", type(e).__qualname__)
span.record_exception(e)
span.set_status(Status(StatusCode.ERROR, str(e)))
raise
# Check if this is a tool use response
is_tool_use_response = (
isinstance(response, CreateMessageResultWithTools)
and response.stop_reason == "toolUse"
)
# Always include the assistant response in history
current_messages.append(SamplingMessage(role="assistant", content=response.content))
# If not a tool use, return immediately
if not is_tool_use_response:
return SampleStep(response=response, history=current_messages)
# If not executing tools, return with assistant message but no tool results
if not auto_execute_tools:
return SampleStep(response=response, history=current_messages)
# Execute tools and add results to history
step_tool_calls = extract_tool_calls(response)
if step_tool_calls:
effective_mask = (
mask_error_details
if mask_error_details is not None
else settings.mask_error_details
)
tool_results: list[ToolResultContent] = await execute_tools(
step_tool_calls,
tool_map,
mask_error_details=effective_mask,
tool_concurrency=tool_concurrency,
)
if tool_results:
current_messages.append(
SamplingMessage(
role="user",
content=cast(list[SamplingMessageContentBlock], tool_results),
)
)
return SampleStep(response=response, history=current_messages)
async def sample_impl(
context: Context,
messages: str | Sequence[str | SamplingMessage],
*,
system_prompt: str | None = None,
temperature: float | None = None,
max_tokens: int | None = None,
model_preferences: ModelPreferences | str | list[str] | None = None,
tools: Sequence[SamplingTool | FunctionTool | TransformedTool | Callable[..., Any]]
| None = None,
result_type: type[ResultT] | None = None,
mask_error_details: bool | None = None,
tool_concurrency: int | None = None,
client_available: bool = True,
) -> SamplingResult[ResultT]:
"""Implementation of Context.sample().
Send a sampling request to the client and await the response. This method
runs to completion automatically, executing a tool loop until the LLM
provides a final text response.
When ``client_available`` is False (e.g. a modern 2026-07-28 connection with
no back-channel), the client is never used and a configured sampling handler
serves the request; the caller is responsible for raising a clear era error
when no handler can serve it.
"""
# Safety limit to prevent infinite loops
max_iterations = 100
# Convert tools to SamplingTools
sampling_tools = prepare_tools(tools)
# Handle structured output with result_type
tool_choice: str | None = None
if result_type is not None and result_type is not str:
final_response_tool = create_final_response_tool(result_type)
sampling_tools = list(sampling_tools) if sampling_tools else []
sampling_tools.append(final_response_tool)
# Always require tool calls when result_type is set - the LLM must
# eventually call final_response (text responses are not accepted)
tool_choice = "required"
# Convert messages for the loop
current_messages: str | Sequence[str | SamplingMessage] = messages
text_response_retries = 0
consecutive_validation_failures = 0
for _iteration in range(max_iterations):
step = await sample_step_impl(
context,
messages=current_messages,
system_prompt=system_prompt,
temperature=temperature,
max_tokens=max_tokens,
model_preferences=model_preferences,
tools=sampling_tools,
tool_choice=tool_choice,
mask_error_details=mask_error_details,
tool_concurrency=tool_concurrency,
client_available=client_available,
)
# Check for final_response tool call for structured output
had_final_response = False
if result_type is not None and result_type is not str and step.is_tool_use:
for tool_call in step.tool_calls:
if tool_call.name == "final_response":
had_final_response = True
# Validate and return the structured result
type_adapter = get_cached_typeadapter(result_type)
# Unwrap if we wrapped primitives (non-object schemas)
input_data = tool_call.input
original_schema = compress_schema(
type_adapter.json_schema(), prune_titles=True
)
if (
original_schema.get("type") != "object"
and isinstance(input_data, dict)
and "value" in input_data
):
input_data = input_data["value"]
try:
validated_result = type_adapter.validate_python(input_data)
text = json.dumps(
type_adapter.dump_python(validated_result, mode="json")
)
return SamplingResult(
text=text,
result=validated_result,
history=step.history,
)
except ValidationError as e:
consecutive_validation_failures += 1
if consecutive_validation_failures > _MAX_VALIDATION_RETRIES:
raise RuntimeError(
f"Structured output validation failed "
f"{consecutive_validation_failures} consecutive "
f"times for type {result_type.__name__}: {e}"
) from e
# Validation failed - add error as tool result
step.history.append(
SamplingMessage(
role="user",
content=[
ToolResultContent(
type="tool_result",
tool_use_id=tool_call.id,
content=[
TextContent(
type="text",
text=(
f"Validation error: {e}. "
"Please try again with valid data."
),
)
],
is_error=True,
)
],
)
)
# The LLM called tools but not final_response — reset validation counter
if not had_final_response:
consecutive_validation_failures = 0
# If not a tool use response, we're done
if not step.is_tool_use:
# For structured output, the LLM must use the final_response tool
if result_type is not None and result_type is not str:
text_response_retries += 1
if text_response_retries > _MAX_TEXT_RESPONSE_RETRIES:
raise RuntimeError(
f"Expected structured output of type {result_type.__name__}, "
"but the LLM returned a text response instead of calling "
f"the final_response tool ({text_response_retries} attempts)."
)
# Nudge the LLM to use the tool
step.history.append(
SamplingMessage(
role="user",
content=TextContent(
type="text",
text=(
"You must call the `final_response` tool to provide "
"your answer. Do not respond with text — use the tool."
),
),
)
)
current_messages = step.history
continue
return SamplingResult(
text=step.text,
result=cast(ResultT, step.text if step.text else ""),
history=step.history,
)
# Continue with the updated history
current_messages = step.history
# After first iteration, reset tool_choice to auto (unless structured output is required)
if result_type is None or result_type is str:
tool_choice = None
raise RuntimeError(f"Sampling exceeded maximum iterations ({max_iterations})")

View file

@ -1,204 +0,0 @@
"""SamplingTool for use during LLM sampling requests."""
from __future__ import annotations
import inspect
from collections.abc import Callable
from typing import Any
from mcp_types import TextContent
from mcp_types import Tool as SDKTool
from pydantic import ConfigDict
from fastmcp.exceptions import AuthorizationError
from fastmcp.server.auth.authorization import AuthContext, run_auth_checks
from fastmcp.server.dependencies import get_access_token
from fastmcp.tools.base import ToolResult
from fastmcp.tools.function_parsing import ParsedFunction
from fastmcp.tools.function_tool import FunctionTool
from fastmcp.tools.tool_transform import TransformedTool
from fastmcp.utilities.types import FastMCPBaseModel
class SamplingTool(FastMCPBaseModel):
"""A tool that can be used during LLM sampling.
SamplingTools bundle a tool's schema (name, description, parameters) with
an executor function, enabling servers to execute agentic workflows where
the LLM can request tool calls during sampling.
In most cases, pass functions directly to ctx.sample():
def search(query: str) -> str:
'''Search the web.'''
return web_search(query)
result = await context.sample(
messages="Find info about Python",
tools=[search], # Plain functions work directly
)
Create a SamplingTool explicitly when you need custom name/description:
tool = SamplingTool.from_function(search, name="web_search")
"""
name: str
description: str | None = None
parameters: dict[str, Any]
fn: Callable[..., Any]
sequential: bool = False
model_config = ConfigDict(arbitrary_types_allowed=True)
async def run(self, arguments: dict[str, Any] | None = None) -> Any:
"""Execute the tool with the given arguments.
Args:
arguments: Dictionary of arguments to pass to the tool function.
Returns:
The result of executing the tool function.
"""
if arguments is None:
arguments = {}
result = self.fn(**arguments)
if inspect.isawaitable(result):
result = await result
return result
def _to_sdk_tool(self) -> SDKTool:
"""Convert to an mcp_types.Tool for SDK compatibility.
This is used internally when passing tools to the MCP SDK's
create_message() method.
"""
return SDKTool(
name=self.name,
description=self.description,
input_schema=self.parameters,
)
@classmethod
def from_function(
cls,
fn: Callable[..., Any],
*,
name: str | None = None,
description: str | None = None,
sequential: bool = False,
) -> SamplingTool:
"""Create a SamplingTool from a function.
The function's signature is analyzed to generate a JSON schema for
the tool's parameters. Type hints are used to determine parameter types.
Args:
fn: The function to create a tool from.
name: Optional name override. Defaults to the function's name.
description: Optional description override. Defaults to the function's docstring.
sequential: If True, this tool requires sequential execution and prevents
parallel execution of all tools in the batch. Set to True for tools
with shared state, file writes, or other operations that cannot run
concurrently. Defaults to False.
Returns:
A SamplingTool wrapping the function.
Raises:
ValueError: If the function is a lambda without a name override.
"""
parsed = ParsedFunction.from_function(fn, validate=True)
if name is None and parsed.name == "<lambda>":
raise ValueError("You must provide a name for lambda functions")
return cls(
name=name or parsed.name,
description=description if description is not None else parsed.description,
parameters=parsed.input_schema,
fn=parsed.fn,
sequential=sequential,
)
@classmethod
def from_callable_tool(
cls,
tool: FunctionTool | TransformedTool,
*,
name: str | None = None,
description: str | None = None,
) -> SamplingTool:
"""Create a SamplingTool from a FunctionTool or TransformedTool.
Reuses existing server tools in sampling contexts. For TransformedTool,
the tool's .run() method is used to ensure proper argument transformation,
and the ToolResult is automatically unwrapped.
Args:
tool: A FunctionTool or TransformedTool to convert.
name: Optional name override. Defaults to tool.name.
description: Optional description override. Defaults to tool.description.
Raises:
TypeError: If the tool is not a FunctionTool or TransformedTool.
"""
# Validate that the tool is a supported type
if not isinstance(tool, (FunctionTool, TransformedTool)):
raise TypeError(
f"Expected FunctionTool or TransformedTool, got {type(tool).__name__}. "
"Only callable tools can be converted to SamplingTools."
)
# Both FunctionTool and TransformedTool need .run() to ensure proper
# result processing (serializers, output_schema, wrap-result flags)
async def wrapper(**kwargs: Any) -> Any:
# Enforce per-tool auth checks, mirroring what the server
# dispatcher does for direct tool calls. Without this, an
# auth-protected tool wrapped as a SamplingTool could be
# invoked by the LLM during sampling without authorization.
if tool.auth is not None:
# Late import to avoid circular import with context.py
from fastmcp.server.context import _current_transport
is_stdio = _current_transport.get() == "stdio"
if not is_stdio:
token = get_access_token()
ctx = AuthContext(token=token, component=tool)
if not await run_auth_checks(tool.auth, ctx):
raise AuthorizationError(
f"Authorization failed for tool '{tool.name}': "
"insufficient permissions"
)
result = await tool.run(kwargs)
# Unwrap ToolResult - extract the actual value
if isinstance(result, ToolResult):
# If there's structured_content, use that
if result.structured_content is not None:
# Check tool's schema - this is the source of truth
if tool.output_schema and tool.output_schema.get(
"x-fastmcp-wrap-result"
):
# Tool wraps results: {"result": value} -> value
return result.structured_content.get("result")
else:
# No wrapping: use structured_content directly
return result.structured_content
# Otherwise, extract from text content
if result.content and len(result.content) > 0:
first_content = result.content[0]
if isinstance(first_content, TextContent):
return first_content.text
return result
fn = wrapper
# Extract the callable function, name, description, and parameters
return cls(
name=name or tool.name,
description=description or tool.description,
parameters=tool.parameters,
fn=fn,
)

View file

@ -103,7 +103,6 @@ from fastmcp.utilities.versions import (
if TYPE_CHECKING:
from fastmcp.client import Client
from fastmcp.client.client import SDKServer
from fastmcp.client.sampling import SamplingHandler
from fastmcp.client.transports import ClientTransport, ClientTransportT
from fastmcp.server.extensions import ServerExtension
from fastmcp.server.providers.openapi import ComponentFn as OpenAPIComponentFn
@ -179,11 +178,13 @@ _REMOVED_KWARGS: dict[str, str] = {
"include_tags": "Use `server.enable(tags=..., only=True)` after creating the server.",
"exclude_tags": "Use `server.disable(tags=...)` after creating the server.",
"tool_transformations": "Use `server.add_transform(ToolTransform(...))` after creating the server.",
"sampling_handler": "Server-initiated sampling was removed from MCP by SEP-2577. Call an LLM directly from your tool.",
"sampling_handler_behavior": "Server-initiated sampling was removed from MCP by SEP-2577. Call an LLM directly from your tool.",
}
def _check_removed_kwargs(kwargs: dict[str, Any]) -> None:
"""Raise helpful TypeErrors for kwargs removed in v3."""
"""Raise helpful TypeErrors for kwargs FastMCP no longer accepts."""
for key in kwargs:
if key in _REMOVED_KWARGS:
raise TypeError(
@ -356,8 +357,6 @@ class FastMCP(
cache_scope: Literal["public", "private"] | None = None,
tasks: bool | None = None,
session_state_store: AsyncKeyValue | None = None,
sampling_handler: SamplingHandler | None = None,
sampling_handler_behavior: Literal["always", "fallback"] | None = None,
client_log_level: mcp_types.LoggingLevel | None = None,
experimental_capabilities: dict[str, dict[str, Any]] | None = None,
**kwargs: Any,
@ -539,11 +538,6 @@ class FastMCP(
# Set up MCP protocol handlers
self._setup_handlers()
self.sampling_handler: SamplingHandler | None = sampling_handler
self.sampling_handler_behavior: Literal["always", "fallback"] = (
sampling_handler_behavior or "fallback"
)
def __repr__(self) -> str:
return f"{type(self).__name__}({self.name!r})"

View file

@ -14,14 +14,13 @@ import logging
import mcp_types
import pytest
from mcp_types import TextContent, ToolUseContent
from mcp_types import TextContent
from pydantic import AnyUrl
from fastmcp.client import Client
from fastmcp.client.mixins.tools import _parse_call_tool_result
from fastmcp.client.transports import FastMCPTransport
from fastmcp.exceptions import PromptError, ResourceError, ToolError
from fastmcp.server.sampling.run import SamplingTool, execute_tools
from fastmcp.server.server import FastMCP
@ -415,38 +414,3 @@ class TestLogLevel:
and record.levelname == "ERROR"
for record in caplog.records
)
async def test_sampling_tool_error_with_custom_log_level(self, caplog):
"""ToolError with custom log_level in sampling should log at specified level."""
async def custom_level_sampling_tool(x: int) -> int:
raise ToolError("Expected sampling error", log_level=logging.WARNING)
tool = SamplingTool.from_function(custom_level_sampling_tool)
tool_use = ToolUseContent(
type="tool_use",
id="test-id",
name="custom_level_sampling_tool",
input={"x": 42},
)
with caplog.at_level(logging.WARNING):
results = await execute_tools(
tool_calls=[tool_use],
tool_map={"custom_level_sampling_tool": tool},
mask_error_details=False,
)
assert len(results) == 1
assert results[0].is_error
assert "Expected sampling error" in results[0].content[0].text # type: ignore
assert any(
"Error calling sampling tool" in record.message
and record.levelname == "WARNING"
for record in caplog.records
)
assert not any(
"Error calling sampling tool" in record.message
and record.levelname == "ERROR"
for record in caplog.records
)

View file

@ -1,18 +1,25 @@
import functools
import pytest
from mcp_types import Root
from fastmcp import Client, Context, FastMCP
@pytest.fixture
def fastmcp_server():
"""A server that issues a handshake-era `roots/list` request.
`Context` has no `list_roots()` server-initiated requests are not part of
FastMCP's server API. This server reaches the SDK session directly to stand
in for a legacy upstream, so the client's `roots=` handling stays covered.
"""
mcp = FastMCP()
@mcp.tool
async def list_roots(context: Context) -> list[str]:
roots = await context.list_roots()
return [str(r.uri) for r in roots]
result = await context.session.list_roots() # ty: ignore[deprecated]
return [str(r.uri) for r in result.roots]
return mcp
@ -38,7 +45,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]):
# ctx.list_roots is a legacy-era server-initiated feature.
# `roots/list` is a server-initiated request, so it only exists on the
# handshake era; SEP-2577 removed it from the modern protocol.
async with Client(fastmcp_server, mode="legacy", roots=roots) as client:
result = await client.call_tool("list_roots", {})
assert result.data == [
@ -46,6 +54,20 @@ class TestClientRoots:
"file://x/y/z",
]
async def test_roots_handler_answers_a_legacy_server(self, fastmcp_server: FastMCP):
"""A callable `roots=` handler still answers a legacy server's request."""
calls: list[object] = []
async def roots_handler(ctx) -> list[Root]:
calls.append(ctx)
return [Root(uri="file://from/handler")]
async with Client(fastmcp_server, mode="legacy", roots=roots_handler) as client:
result = await client.call_tool("list_roots", {})
assert len(calls) == 1
assert result.data == ["file://from/handler"]
async def test_bound_method_roots_handler(self, fastmcp_server: FastMCP):
class RootsProvider:
async def get_roots(self, _context: object) -> list[str]:

View file

@ -1,71 +1,97 @@
import json
from typing import cast
from unittest.mock import AsyncMock
import mcp_types
import pytest
from mcp_types import TextContent
from pydantic_core import to_json
from fastmcp import Client, Context, FastMCP
from fastmcp.client.sampling import RequestContext, SamplingMessage, SamplingParams
from fastmcp.server.sampling import SamplingResult, SamplingTool
from fastmcp.utilities.types import Image
async def _sample(
context: Context,
messages: list[SamplingMessage],
*,
system_prompt: str | None = None,
) -> str:
"""Issue a handshake-era `sampling/createMessage` request from a server.
`Context` has no `sample()` server-initiated sampling is not part of
FastMCP's server API. These tests cover the *client* side, which must keep
answering a legacy server, so the stand-in server reaches the SDK session
directly.
"""
result = await context.session.create_message( # ty: ignore[deprecated]
messages=messages,
system_prompt=system_prompt,
max_tokens=512,
related_request_id=context.origin_request_id,
)
assert isinstance(result.content, TextContent)
return result.content.text
@pytest.fixture
def fastmcp_server():
mcp = FastMCP()
@mcp.tool
async def simple_sample(message: str, context: Context) -> str:
result = await context.sample("Hello, world!")
assert isinstance(result, SamplingResult)
assert result.text is not None
return result.text
return await _sample(
context,
[
SamplingMessage(
role="user",
content=TextContent(type="text", text="Hello, world!"),
)
],
)
@mcp.tool
async def sample_with_system_prompt(message: str, context: Context) -> str:
result = await context.sample("Hello, world!", system_prompt="You love FastMCP")
assert isinstance(result, SamplingResult)
assert result.text is not None
return result.text
return await _sample(
context,
[
SamplingMessage(
role="user",
content=TextContent(type="text", text="Hello, world!"),
)
],
system_prompt="You love FastMCP",
)
@mcp.tool
async def sample_with_messages(message: str, context: Context) -> str:
result = await context.sample(
return await _sample(
context,
[
"Hello!",
SamplingMessage(
role="user", content=TextContent(type="text", text="Hello!")
),
SamplingMessage(
role="assistant",
content=TextContent(
type="text", text="How can I assist you today?"
),
role="assistant",
),
]
],
)
assert isinstance(result, SamplingResult)
assert result.text is not None
return result.text
@mcp.tool
async def sample_with_image(image_bytes: bytes, context: Context) -> str:
image = Image(data=image_bytes)
result = await context.sample(
return await _sample(
context,
[
SamplingMessage(
content=TextContent(type="text", text="What's in this image?"),
role="user",
),
SamplingMessage(
content=image.to_image_content(),
role="user",
),
]
SamplingMessage(content=image.to_image_content(), role="user"),
],
)
assert isinstance(result, SamplingResult)
assert result.text is not None
return result.text
return mcp
@ -123,26 +149,6 @@ async def test_sampling_with_messages(fastmcp_server: FastMCP):
assert result.data == "I need to think."
async def test_sampling_with_fallback(fastmcp_server: FastMCP):
openai_sampling_handler = AsyncMock(return_value="But I need to think")
fastmcp_server = FastMCP(
sampling_handler=openai_sampling_handler,
)
@fastmcp_server.tool
async def sample_with_fallback(context: Context) -> str:
sampling_result = await context.sample("Do not think.")
return cast(TextContent, sampling_result).text
client = Client(fastmcp_server)
async with client:
call_tool_result = await client.call_tool("sample_with_fallback")
assert call_tool_result.data == "But I need to think"
async def test_sampling_with_image(fastmcp_server: FastMCP):
def sampling_handler(
messages: list[SamplingMessage], params: SamplingParams, ctx: RequestContext
@ -193,8 +199,6 @@ class TestSamplingDefaultCapabilities:
{"sampling": {"tools": {}}}, ensuring compatibility with servers
that don't recognize the tools sub-field (e.g. older Java MCP SDK).
"""
import mcp_types
server = FastMCP()
def handler(
@ -209,8 +213,6 @@ class TestSamplingDefaultCapabilities:
async def test_set_sampling_callback_default_capabilities_omit_tools(self):
"""set_sampling_callback should also default to no tools capability."""
import mcp_types
server = FastMCP()
client = Client(server)
client.set_sampling_callback(lambda msgs, params, ctx: "ok")
@ -220,8 +222,6 @@ class TestSamplingDefaultCapabilities:
async def test_explicit_tools_capability_is_preserved(self):
"""Explicitly passing tools capability should be respected."""
import mcp_types
server = FastMCP()
def handler(
@ -238,118 +238,3 @@ class TestSamplingDefaultCapabilities:
caps = client._session_kwargs["sampling_capabilities"]
assert isinstance(caps, mcp_types.SamplingCapability)
assert caps.tools is not None
class TestSamplingWithTools:
"""Tests for sampling with tools functionality."""
async def test_sampling_with_tools_requires_capability(self):
"""Test that sampling with tools raises error when client lacks capability."""
import mcp_types
from fastmcp.exceptions import ToolError
server = FastMCP()
def search(query: str) -> str:
"""Search the web."""
return f"Results for: {query}"
@server.tool
async def sample_with_tool(context: Context) -> str:
# This should fail because the client doesn't advertise tools capability
result = await context.sample(
messages="Search for Python tutorials",
tools=[search],
)
return str(result)
def sampling_handler(
messages: list[SamplingMessage], params: SamplingParams, ctx: RequestContext
) -> str:
return "Response"
# 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:
with pytest.raises(ToolError, match="sampling.tools capability"):
await client.call_tool("sample_with_tool", {})
async def test_sampling_with_tools_fallback_handler_can_return_string(self):
"""Test that fallback handler can return a string even when tools are provided.
The LLM might choose not to use any tools and just return a text response.
"""
# This handler returns a string - valid even when tools are provided
simple_handler = AsyncMock(return_value="Direct response without tools")
mcp = FastMCP(sampling_handler=simple_handler)
def search(query: str) -> str:
"""Search the web."""
return f"Results for: {query}"
@mcp.tool
async def sample_with_tool(context: Context) -> str:
result = await context.sample(
messages="Search for Python tutorials",
tools=[search],
)
return result.text or "no text"
# Client without sampling handler - will use server's fallback
async with Client(mcp) as client:
result = await client.call_tool("sample_with_tool", {})
# Handler returned string directly, which is treated as final text response
assert result.data == "Direct response without tools"
def test_sampling_tool_schema(self):
"""Test that SamplingTool generates correct schema."""
def search(query: str, limit: int = 10) -> str:
"""Search the web for results."""
return f"Results for: {query}"
tool = SamplingTool.from_function(search)
assert tool.name == "search"
assert tool.description == "Search the web for results."
assert "query" in tool.parameters.get("properties", {})
assert "limit" in tool.parameters.get("properties", {})
async def test_sampling_tool_run(self):
"""Test that SamplingTool.run() executes correctly."""
def add(a: int, b: int) -> int:
"""Add two numbers."""
return a + b
tool = SamplingTool.from_function(add)
result = await tool.run({"a": 5, "b": 3})
assert result == 8
async def test_sampling_tool_run_async(self):
"""Test that SamplingTool.run() works with async functions."""
async def async_multiply(a: int, b: int) -> int:
"""Multiply two numbers."""
return a * b
tool = SamplingTool.from_function(async_multiply)
result = await tool.run({"a": 4, "b": 7})
assert result == 28
def test_tool_choice_parameter(self):
"""Test that tool_choice parameter accepts string literals."""
from fastmcp.server.context import ToolChoiceOption
# Verify ToolChoiceOption type accepts the valid string values
choices: list[ToolChoiceOption] = ["auto", "required", "none"]
assert len(choices) == 3
assert "auto" in choices
assert "required" in choices
assert "none" in choices

View file

@ -1,681 +0,0 @@
import pytest
from mcp_types import CreateMessageResultWithTools, TextContent, ToolUseContent
from fastmcp import Client, Context, FastMCP
from fastmcp.client.sampling import RequestContext, SamplingMessage, SamplingParams
class TestSamplingResultType:
"""Tests for result_type parameter (structured output)."""
async def test_result_type_creates_final_response_tool(self):
"""Test that result_type creates a synthetic final_response tool."""
from mcp_types import CreateMessageResultWithTools, ToolUseContent
from pydantic import BaseModel
class MathResult(BaseModel):
answer: int
explanation: str
received_tools: list = []
def sampling_handler(
messages: list[SamplingMessage], params: SamplingParams, ctx: RequestContext
) -> CreateMessageResultWithTools:
received_tools.extend(params.tools or [])
# Return the final_response tool call
return CreateMessageResultWithTools(
role="assistant",
content=[
ToolUseContent(
type="tool_use",
id="call_1",
name="final_response",
input={"answer": 42, "explanation": "The meaning of life"},
)
],
model="test-model",
stop_reason="toolUse",
)
mcp = FastMCP(sampling_handler=sampling_handler)
@mcp.tool
async def math_tool(context: Context) -> str:
result = await context.sample(
messages="What is 6 * 7?",
result_type=MathResult,
)
# result.result should be a MathResult object
assert isinstance(result.result, MathResult)
return f"{result.result.answer}: {result.result.explanation}"
async with Client(mcp) as client:
result = await client.call_tool("math_tool", {})
# Check that final_response tool was added
tool_names = [t.name for t in received_tools]
assert "final_response" in tool_names
# Check the result
assert result.data == "42: The meaning of life"
async def test_result_type_with_user_tools(self):
"""Test result_type works alongside user-provided tools."""
from mcp_types import CreateMessageResultWithTools, ToolUseContent
from pydantic import BaseModel
class SearchResult(BaseModel):
summary: str
sources: list[str]
def search(query: str) -> str:
"""Search for information."""
return f"Found info about: {query}"
call_count = 0
tool_was_called = False
def sampling_handler(
messages: list[SamplingMessage], params: SamplingParams, ctx: RequestContext
) -> CreateMessageResultWithTools:
nonlocal call_count, tool_was_called
call_count += 1
if call_count == 1:
# First call: use the search tool
return CreateMessageResultWithTools(
role="assistant",
content=[
ToolUseContent(
type="tool_use",
id="call_1",
name="search",
input={"query": "Python tutorials"},
)
],
model="test-model",
stop_reason="toolUse",
)
else:
# Second call: call final_response
tool_was_called = True
return CreateMessageResultWithTools(
role="assistant",
content=[
ToolUseContent(
type="tool_use",
id="call_2",
name="final_response",
input={
"summary": "Python is great",
"sources": ["python.org", "docs.python.org"],
},
)
],
model="test-model",
stop_reason="toolUse",
)
mcp = FastMCP(sampling_handler=sampling_handler)
@mcp.tool
async def research(context: Context) -> str:
result = await context.sample(
messages="Research Python",
tools=[search],
result_type=SearchResult,
)
assert isinstance(result.result, SearchResult)
return f"{result.result.summary} - {len(result.result.sources)} sources"
async with Client(mcp) as client:
result = await client.call_tool("research", {})
assert tool_was_called
assert result.data == "Python is great - 2 sources"
async def test_result_type_validation_error_retries(self):
"""Test that validation errors are sent back to LLM for retry."""
from mcp_types import (
CreateMessageResultWithTools,
ToolResultContent,
ToolUseContent,
)
from pydantic import BaseModel
class StrictResult(BaseModel):
value: int # Must be an int
messages_received: list[list[SamplingMessage]] = []
def sampling_handler(
messages: list[SamplingMessage], params: SamplingParams, ctx: RequestContext
) -> CreateMessageResultWithTools:
messages_received.append(list(messages))
if len(messages_received) == 1:
# First call: invalid type
return CreateMessageResultWithTools(
role="assistant",
content=[
ToolUseContent(
type="tool_use",
id="call_1",
name="final_response",
input={"value": "not_an_int"}, # Wrong type
)
],
model="test-model",
stop_reason="toolUse",
)
else:
# Second call: valid type after seeing error
return CreateMessageResultWithTools(
role="assistant",
content=[
ToolUseContent(
type="tool_use",
id="call_2",
name="final_response",
input={"value": 42}, # Correct type
)
],
model="test-model",
stop_reason="toolUse",
)
mcp = FastMCP(sampling_handler=sampling_handler)
@mcp.tool
async def validate_tool(context: Context) -> str:
result = await context.sample(
messages="Give me a number",
result_type=StrictResult,
)
assert isinstance(result.result, StrictResult)
return str(result.result.value)
async with Client(mcp) as client:
result = await client.call_tool("validate_tool", {})
# Should have retried after validation error
assert len(messages_received) == 2
# Check that error was passed back
last_messages = messages_received[1]
# Find the tool result in list content
tool_result = None
for msg in last_messages:
# Tool results are now in a list
if isinstance(msg.content, list):
for item in msg.content:
if isinstance(item, ToolResultContent):
tool_result = item
break
elif isinstance(msg.content, ToolResultContent):
tool_result = msg.content
break
assert tool_result is not None
assert tool_result.is_error is True
assert isinstance(tool_result.content[0], TextContent)
error_text = tool_result.content[0].text
assert "Validation error" in error_text
# Final result should be correct
assert result.data == "42"
async def test_sampling_result_has_text_and_history(self):
"""Test that SamplingResult has text, result, and history attributes."""
from mcp_types import CreateMessageResultWithTools
def sampling_handler(
messages: list[SamplingMessage], params: SamplingParams, ctx: RequestContext
) -> CreateMessageResultWithTools:
return CreateMessageResultWithTools(
role="assistant",
content=[TextContent(type="text", text="Hello world")],
model="test-model",
stop_reason="endTurn",
)
mcp = FastMCP(sampling_handler=sampling_handler)
@mcp.tool
async def check_result(context: Context) -> str:
result = await context.sample(messages="Say hello")
# Check all attributes exist
assert result.text == "Hello world"
assert result.result == "Hello world"
assert len(result.history) >= 1
return "ok"
async with Client(mcp) as client:
result = await client.call_tool("check_result", {})
assert result.data == "ok"
class TestSampleStep:
"""Tests for ctx.sample_step() - single LLM call with manual control."""
async def test_sample_step_basic(self):
"""Test basic sample_step returns text response."""
from mcp_types import CreateMessageResultWithTools
def sampling_handler(
messages: list[SamplingMessage], params: SamplingParams, ctx: RequestContext
) -> CreateMessageResultWithTools:
return CreateMessageResultWithTools(
role="assistant",
content=[TextContent(type="text", text="Hello from step")],
model="test-model",
stop_reason="endTurn",
)
mcp = FastMCP(sampling_handler=sampling_handler)
@mcp.tool
async def test_step(context: Context) -> str:
step = await context.sample_step(messages="Hi")
assert not step.is_tool_use
assert step.text == "Hello from step"
return step.text or ""
async with Client(mcp) as client:
result = await client.call_tool("test_step", {})
assert result.data == "Hello from step"
async def test_sample_step_with_tool_execution(self):
"""Test sample_step executes tools by default."""
from mcp_types import CreateMessageResultWithTools, ToolUseContent
call_count = 0
def my_tool(x: int) -> str:
"""A test tool."""
return f"result:{x}"
def sampling_handler(
messages: list[SamplingMessage], params: SamplingParams, ctx: RequestContext
) -> CreateMessageResultWithTools:
nonlocal call_count
call_count += 1
if call_count == 1:
return CreateMessageResultWithTools(
role="assistant",
content=[
ToolUseContent(
type="tool_use",
id="call_1",
name="my_tool",
input={"x": 42},
)
],
model="test-model",
stop_reason="toolUse",
)
else:
return CreateMessageResultWithTools(
role="assistant",
content=[TextContent(type="text", text="Done")],
model="test-model",
stop_reason="endTurn",
)
mcp = FastMCP(sampling_handler=sampling_handler)
@mcp.tool
async def test_step(context: Context) -> str:
messages: str | list[SamplingMessage] = "Run tool"
while True:
step = await context.sample_step(messages=messages, tools=[my_tool])
if not step.is_tool_use:
return step.text or ""
# History should include tool results when execute_tools=True
messages = step.history
async with Client(mcp) as client:
result = await client.call_tool("test_step", {})
assert result.data == "Done"
assert call_count == 2
async def test_sample_step_execute_tools_false(self):
"""Test sample_step with execute_tools=False doesn't execute tools."""
from mcp_types import CreateMessageResultWithTools, ToolUseContent
tool_executed = False
def my_tool() -> str:
"""A test tool."""
nonlocal tool_executed
tool_executed = True
return "executed"
def sampling_handler(
messages: list[SamplingMessage], params: SamplingParams, ctx: RequestContext
) -> CreateMessageResultWithTools:
return CreateMessageResultWithTools(
role="assistant",
content=[
ToolUseContent(
type="tool_use",
id="call_1",
name="my_tool",
input={},
)
],
model="test-model",
stop_reason="toolUse",
)
mcp = FastMCP(sampling_handler=sampling_handler)
@mcp.tool
async def test_step(context: Context) -> str:
step = await context.sample_step(
messages="Run tool",
tools=[my_tool],
execute_tools=False,
)
assert step.is_tool_use
assert len(step.tool_calls) == 1
assert step.tool_calls[0].name == "my_tool"
# History should include assistant message but no tool results
assert len(step.history) == 2 # user + assistant
return "ok"
async with Client(mcp) as client:
result = await client.call_tool("test_step", {})
assert result.data == "ok"
assert not tool_executed # Tool should not have been executed
async def test_sample_step_history_includes_assistant_message(self):
"""Test that history includes assistant message when execute_tools=False."""
from mcp_types import CreateMessageResultWithTools, ToolUseContent
def sampling_handler(
messages: list[SamplingMessage], params: SamplingParams, ctx: RequestContext
) -> CreateMessageResultWithTools:
return CreateMessageResultWithTools(
role="assistant",
content=[
ToolUseContent(
type="tool_use",
id="call_1",
name="my_tool",
input={"query": "test"},
)
],
model="test-model",
stop_reason="toolUse",
)
mcp = FastMCP(sampling_handler=sampling_handler)
def my_tool(query: str) -> str:
return f"result for {query}"
@mcp.tool
async def test_step(context: Context) -> str:
step = await context.sample_step(
messages="Search",
tools=[my_tool],
execute_tools=False,
)
# History should have: user message + assistant message
assert len(step.history) == 2
assert step.history[0].role == "user"
assert step.history[1].role == "assistant"
return "ok"
async with Client(mcp) as client:
result = await client.call_tool("test_step", {})
assert result.data == "ok"
class TestTextResponseRetry:
"""Tests for retry logic when LLM returns text instead of calling final_response."""
@staticmethod
def _text_reply(text: str = "some text"):
from mcp_types import CreateMessageResultWithTools
return CreateMessageResultWithTools(
role="assistant",
content=[TextContent(type="text", text=text)],
model="m",
stop_reason="endTurn",
)
@staticmethod
def _tool_reply(value: int):
from mcp_types import CreateMessageResultWithTools, ToolUseContent
return CreateMessageResultWithTools(
role="assistant",
content=[
ToolUseContent(
type="tool_use",
id="c1",
name="final_response",
input={"value": value},
)
],
model="m",
stop_reason="toolUse",
)
async def test_text_response_then_success(self):
"""Text on first call, final_response on second -- verify call_count == 2."""
from pydantic import BaseModel
class R(BaseModel):
value: int
call_count = 0
def handler(messages, params, ctx):
nonlocal call_count
call_count += 1
return self._text_reply() if call_count == 1 else self._tool_reply(42)
mcp = FastMCP(sampling_handler=handler)
@mcp.tool
async def t(context: Context) -> str:
return str((await context.sample(messages="q", result_type=R)).result.value)
async with Client(mcp) as client:
result = await client.call_tool("t", {})
assert call_count == 2
assert result.data == "42"
async def test_text_response_exceeds_max_retries(self):
"""Always text, never tool -- verify error after _MAX_TEXT_RESPONSE_RETRIES+1 calls."""
from pydantic import BaseModel
from fastmcp.exceptions import ToolError
from fastmcp.server.sampling.run import _MAX_TEXT_RESPONSE_RETRIES
class R(BaseModel):
value: int
call_count = 0
def handler(messages, params, ctx):
nonlocal call_count
call_count += 1
return self._text_reply()
mcp = FastMCP(sampling_handler=handler)
@mcp.tool
async def t(context: Context) -> str:
return str((await context.sample(messages="q", result_type=R)).result)
async with Client(mcp) as client:
with pytest.raises(ToolError, match="attempts"):
await client.call_tool("t", {})
assert call_count == _MAX_TEXT_RESPONSE_RETRIES + 1
async def test_no_retry_when_result_type_is_none(self):
"""Text response with no result_type -- single call, normal return."""
call_count = 0
def handler(messages, params, ctx):
nonlocal call_count
call_count += 1
return self._text_reply("hello")
mcp = FastMCP(sampling_handler=handler)
@mcp.tool
async def t(context: Context) -> str:
return (await context.sample(messages="q")).text or ""
async with Client(mcp) as client:
result = await client.call_tool("t", {})
assert call_count == 1
assert result.data == "hello"
def _final_response(call_id: str, input_data: dict) -> CreateMessageResultWithTools:
"""Build a final_response tool-use reply."""
return CreateMessageResultWithTools(
role="assistant",
content=[
ToolUseContent(
type="tool_use", id=call_id, name="final_response", input=input_data
)
],
model="test-model",
stop_reason="toolUse",
)
def _tool_call(
call_id: str, name: str, input_data: dict
) -> CreateMessageResultWithTools:
"""Build a regular tool-use reply."""
return CreateMessageResultWithTools(
role="assistant",
content=[
ToolUseContent(type="tool_use", id=call_id, name=name, input=input_data)
],
model="test-model",
stop_reason="toolUse",
)
class TestValidationRetryCap:
"""Tests for the consecutive validation retry cap (PR #3851)."""
async def test_validation_failures_within_cap_then_success(self):
"""Two consecutive failures followed by a valid response succeeds."""
from pydantic import BaseModel
class R(BaseModel):
value: int
call_count = 0
def handler(messages, params, ctx):
nonlocal call_count
call_count += 1
if call_count <= 2:
return _final_response(f"c{call_count}", {"value": "bad"})
return _final_response(f"c{call_count}", {"value": 99})
mcp = FastMCP(sampling_handler=handler)
@mcp.tool
async def t(context: Context) -> str:
r = await context.sample(messages="go", result_type=R)
return str(r.result.value)
async with Client(mcp) as client:
result = await client.call_tool("t", {})
assert call_count == 3
assert result.data == "99"
async def test_consecutive_validation_failures_exceed_cap(self):
"""Always-invalid responses raise ToolError after exceeding the cap."""
from pydantic import BaseModel
from fastmcp.exceptions import ToolError
from fastmcp.server.sampling.run import _MAX_VALIDATION_RETRIES
class R(BaseModel):
value: int
call_count = 0
def handler(messages, params, ctx):
nonlocal call_count
call_count += 1
return _final_response(f"c{call_count}", {"value": "wrong"})
mcp = FastMCP(sampling_handler=handler)
@mcp.tool
async def t(context: Context) -> str:
return str((await context.sample(messages="go", result_type=R)).result)
async with Client(mcp) as client:
with pytest.raises(ToolError, match="consecutive"):
await client.call_tool("t", {})
# 1 initial attempt + _MAX_VALIDATION_RETRIES retries
assert call_count == _MAX_VALIDATION_RETRIES + 1
async def test_validation_counter_resets_after_other_tool_call(self):
"""A tool call between validation failures resets the counter."""
from pydantic import BaseModel
class R(BaseModel):
value: int
def helper_tool(x: int) -> str:
"""A helper tool."""
return f"result:{x}"
call_count = 0
def handler(messages, params, ctx):
nonlocal call_count
call_count += 1
# fail -> other tool (resets counter) -> fail -> succeed
if call_count == 1:
return _final_response("c1", {"value": "bad"})
if call_count == 2:
return _tool_call("c2", "helper_tool", {"x": 1})
if call_count == 3:
return _final_response("c3", {"value": "bad"})
return _final_response("c4", {"value": 42})
mcp = FastMCP(sampling_handler=handler)
@mcp.tool
async def t(context: Context) -> str:
r = await context.sample(messages="go", tools=[helper_tool], result_type=R)
return str(r.result.value)
async with Client(mcp) as client:
result = await client.call_tool("t", {})
assert call_count == 4
assert result.data == "42"

View file

@ -1,811 +0,0 @@
from typing import cast
from mcp_types import TextContent
from fastmcp import Client, Context, FastMCP
from fastmcp.client.sampling import RequestContext, SamplingMessage, SamplingParams
from fastmcp.server.sampling import SamplingTool
class TestAutomaticToolLoop:
"""Tests for automatic tool execution loop in ctx.sample()."""
async def test_automatic_tool_loop_executes_tools(self):
"""Test that ctx.sample() automatically executes tool calls."""
from mcp_types import CreateMessageResultWithTools, ToolUseContent
call_count = 0
tool_was_called = False
def get_weather(city: str) -> str:
"""Get weather for a city."""
nonlocal tool_was_called
tool_was_called = True
return f"Weather in {city}: sunny, 72°F"
def sampling_handler(
messages: list[SamplingMessage], params: SamplingParams, ctx: RequestContext
) -> CreateMessageResultWithTools:
nonlocal call_count
call_count += 1
if call_count == 1:
# First call: return tool use
return CreateMessageResultWithTools(
role="assistant",
content=[
ToolUseContent(
type="tool_use",
id="call_1",
name="get_weather",
input={"city": "Seattle"},
)
],
model="test-model",
stop_reason="toolUse",
)
else:
# Second call: return final response
return CreateMessageResultWithTools(
role="assistant",
content=[TextContent(type="text", text="The weather is sunny!")],
model="test-model",
stop_reason="endTurn",
)
mcp = FastMCP(sampling_handler=sampling_handler)
@mcp.tool
async def weather_assistant(question: str, context: Context) -> str:
result = await context.sample(
messages=question,
tools=[get_weather],
)
# Get text from SamplingResult
return result.text or ""
async with Client(mcp) as client:
result = await client.call_tool(
"weather_assistant", {"question": "What's the weather?"}
)
assert tool_was_called
assert call_count == 2
assert result.data == "The weather is sunny!"
async def test_automatic_tool_loop_multiple_tools(self):
"""Test that multiple tool calls in one response are all executed."""
from mcp_types import CreateMessageResultWithTools, ToolUseContent
executed_tools: list[str] = []
def tool_a(x: int) -> int:
"""Tool A."""
executed_tools.append(f"tool_a({x})")
return x * 2
def tool_b(y: int) -> int:
"""Tool B."""
executed_tools.append(f"tool_b({y})")
return y + 10
call_count = 0
def sampling_handler(
messages: list[SamplingMessage], params: SamplingParams, ctx: RequestContext
) -> CreateMessageResultWithTools:
nonlocal call_count
call_count += 1
if call_count == 1:
# Return multiple tool calls
return CreateMessageResultWithTools(
role="assistant",
content=[
ToolUseContent(
type="tool_use", id="call_a", name="tool_a", input={"x": 5}
),
ToolUseContent(
type="tool_use", id="call_b", name="tool_b", input={"y": 3}
),
],
model="test-model",
stop_reason="toolUse",
)
else:
return CreateMessageResultWithTools(
role="assistant",
content=[TextContent(type="text", text="Done!")],
model="test-model",
stop_reason="endTurn",
)
mcp = FastMCP(sampling_handler=sampling_handler)
@mcp.tool
async def multi_tool(context: Context) -> str:
result = await context.sample(messages="Run tools", tools=[tool_a, tool_b])
return result.text or ""
async with Client(mcp) as client:
result = await client.call_tool("multi_tool", {})
assert executed_tools == ["tool_a(5)", "tool_b(3)"]
assert result.data == "Done!"
async def test_automatic_tool_loop_handles_unknown_tool(self):
"""Test that unknown tool names result in error being passed to LLM."""
from mcp_types import (
CreateMessageResultWithTools,
ToolResultContent,
ToolUseContent,
)
def known_tool() -> str:
"""A known tool."""
return "known result"
messages_received: list[list[SamplingMessage]] = []
def sampling_handler(
messages: list[SamplingMessage], params: SamplingParams, ctx: RequestContext
) -> CreateMessageResultWithTools:
messages_received.append(list(messages))
if len(messages_received) == 1:
# Request unknown tool
return CreateMessageResultWithTools(
role="assistant",
content=[
ToolUseContent(
type="tool_use",
id="call_1",
name="unknown_tool",
input={},
)
],
model="test-model",
stop_reason="toolUse",
)
else:
return CreateMessageResultWithTools(
role="assistant",
content=[TextContent(type="text", text="Handled error")],
model="test-model",
stop_reason="endTurn",
)
mcp = FastMCP(sampling_handler=sampling_handler)
@mcp.tool
async def test_unknown(context: Context) -> str:
result = await context.sample(messages="Test", tools=[known_tool])
return result.text or ""
async with Client(mcp) as client:
result = await client.call_tool("test_unknown", {})
# Check that error was passed back in messages
assert len(messages_received) == 2
last_messages = messages_received[1]
# Find the tool result in list content
tool_result = None
for msg in last_messages:
# Tool results are now in a list
if isinstance(msg.content, list):
for item in msg.content:
if isinstance(item, ToolResultContent):
tool_result = item
break
elif isinstance(msg.content, ToolResultContent):
tool_result = msg.content
break
assert tool_result is not None
assert tool_result.is_error is True
# Content is list of TextContent objects
assert isinstance(tool_result.content[0], TextContent)
error_text = tool_result.content[0].text
assert "Unknown tool" in error_text
assert result.data == "Handled error"
async def test_automatic_tool_loop_handles_tool_exception(self):
"""Test that tool exceptions are caught and passed to LLM as errors."""
from mcp_types import (
CreateMessageResultWithTools,
ToolResultContent,
ToolUseContent,
)
def failing_tool() -> str:
"""A tool that raises an exception."""
raise ValueError("Tool failed intentionally")
messages_received: list[list[SamplingMessage]] = []
def sampling_handler(
messages: list[SamplingMessage], params: SamplingParams, ctx: RequestContext
) -> CreateMessageResultWithTools:
messages_received.append(list(messages))
if len(messages_received) == 1:
return CreateMessageResultWithTools(
role="assistant",
content=[
ToolUseContent(
type="tool_use",
id="call_1",
name="failing_tool",
input={},
)
],
model="test-model",
stop_reason="toolUse",
)
else:
return CreateMessageResultWithTools(
role="assistant",
content=[TextContent(type="text", text="Handled error")],
model="test-model",
stop_reason="endTurn",
)
mcp = FastMCP(sampling_handler=sampling_handler)
@mcp.tool
async def test_exception(context: Context) -> str:
result = await context.sample(messages="Test", tools=[failing_tool])
return result.text or ""
async with Client(mcp) as client:
result = await client.call_tool("test_exception", {})
# Check that error was passed back
assert len(messages_received) == 2
last_messages = messages_received[1]
# Find the tool result in list content
tool_result = None
for msg in last_messages:
# Tool results are now in a list
if isinstance(msg.content, list):
for item in msg.content:
if isinstance(item, ToolResultContent):
tool_result = item
break
elif isinstance(msg.content, ToolResultContent):
tool_result = msg.content
break
assert tool_result is not None
assert tool_result.is_error is True
# Content is list of TextContent objects
assert isinstance(tool_result.content[0], TextContent)
error_text = tool_result.content[0].text
assert "Tool failed intentionally" in error_text
assert result.data == "Handled error"
async def test_concurrent_tool_execution_default_sequential(self):
"""Test that tools execute sequentially by default."""
import asyncio
from mcp_types import CreateMessageResultWithTools, ToolUseContent
# Ordering is guaranteed structurally (the loop awaits each tool call
# to completion before starting the next when tool_concurrency is
# None), so no real delay is needed to prove it - a single
# `asyncio.sleep(0)` still yields control to the event loop.
execution_order: list[str] = []
async def slow_tool_a(x: int) -> int:
"""Slow tool A."""
execution_order.append("tool_a_start")
await asyncio.sleep(0)
execution_order.append("tool_a_end")
return x * 2
async def slow_tool_b(y: int) -> int:
"""Slow tool B."""
execution_order.append("tool_b_start")
await asyncio.sleep(0)
execution_order.append("tool_b_end")
return y + 10
call_count = 0
def sampling_handler(
messages: list[SamplingMessage], params: SamplingParams, ctx: RequestContext
) -> CreateMessageResultWithTools:
nonlocal call_count
call_count += 1
if call_count == 1:
return CreateMessageResultWithTools(
role="assistant",
content=[
ToolUseContent(
type="tool_use",
id="call_a",
name="slow_tool_a",
input={"x": 5},
),
ToolUseContent(
type="tool_use",
id="call_b",
name="slow_tool_b",
input={"y": 3},
),
],
model="test-model",
stop_reason="toolUse",
)
else:
return CreateMessageResultWithTools(
role="assistant",
content=[TextContent(type="text", text="Done!")],
model="test-model",
stop_reason="endTurn",
)
mcp = FastMCP(sampling_handler=sampling_handler)
@mcp.tool
async def test_tool(context: Context) -> str:
result = await context.sample(
messages="Run tools",
tools=[slow_tool_a, slow_tool_b],
# Default: tool_concurrency=None (sequential)
)
return result.text or ""
async with Client(mcp) as client:
result = await client.call_tool("test_tool", {})
assert result.data == "Done!"
# Verify sequential execution: tool_a must complete before tool_b starts
assert execution_order == [
"tool_a_start",
"tool_a_end",
"tool_b_start",
"tool_b_end",
]
async def test_concurrent_tool_execution_unlimited(self):
"""Test unlimited parallel tool execution with tool_concurrency=0."""
import asyncio
from mcp_types import CreateMessageResultWithTools, ToolUseContent
# tool_a blocks on an event that only tool_b sets. This is only
# satisfiable if both tools are genuinely running concurrently: under
# sequential execution tool_b would never start (tool_a would never
# finish awaiting it) and the test would fail via the wait_for
# timeout rather than racing on wall-clock timestamps.
execution_order: list[str] = []
tool_b_started = asyncio.Event()
async def slow_tool_a(x: int) -> int:
"""Slow tool A."""
execution_order.append("tool_a_start")
await asyncio.wait_for(tool_b_started.wait(), timeout=1.0)
execution_order.append("tool_a_end")
return x * 2
async def slow_tool_b(y: int) -> int:
"""Slow tool B."""
execution_order.append("tool_b_start")
tool_b_started.set()
execution_order.append("tool_b_end")
return y + 10
call_count = 0
def sampling_handler(
messages: list[SamplingMessage], params: SamplingParams, ctx: RequestContext
) -> CreateMessageResultWithTools:
nonlocal call_count
call_count += 1
if call_count == 1:
return CreateMessageResultWithTools(
role="assistant",
content=[
ToolUseContent(
type="tool_use",
id="call_a",
name="slow_tool_a",
input={"x": 5},
),
ToolUseContent(
type="tool_use",
id="call_b",
name="slow_tool_b",
input={"y": 3},
),
],
model="test-model",
stop_reason="toolUse",
)
else:
return CreateMessageResultWithTools(
role="assistant",
content=[TextContent(type="text", text="Done!")],
model="test-model",
stop_reason="endTurn",
)
mcp = FastMCP(sampling_handler=sampling_handler)
@mcp.tool
async def test_tool(context: Context) -> str:
result = await context.sample(
messages="Run tools",
tools=[slow_tool_a, slow_tool_b],
tool_concurrency=0, # Unlimited parallel
)
return result.text or ""
async with Client(mcp) as client:
result = await client.call_tool("test_tool", {})
assert result.data == "Done!"
# Verify parallel execution: tool_b started and finished entirely
# inside tool_a's blocked wait, which is only possible if the two
# tools were running concurrently.
assert execution_order == [
"tool_a_start",
"tool_b_start",
"tool_b_end",
"tool_a_end",
]
async def test_concurrent_tool_execution_bounded(self):
"""Test bounded parallel execution with tool_concurrency=2."""
import asyncio
from mcp_types import CreateMessageResultWithTools, ToolUseContent
# tool_1 and tool_2 each block until *both* have started, which is
# only possible if two slots are occupied simultaneously (proving
# concurrency=2 admits two tools at once). tool_3 has no blocking
# branch, so its appearance in the log tells us when the real
# semaphore in the implementation let it in - only after a slot
# frees, i.e. after tool_1 or tool_2 finishes.
execution_order: list[str] = []
both_started = asyncio.Event()
started_names: set[str] = set()
async def slow_tool(name: str) -> str:
"""Generic tool used to observe bounded concurrency."""
execution_order.append(f"{name}_start")
if name in ("tool_1", "tool_2"):
started_names.add(name)
if {"tool_1", "tool_2"} <= started_names:
both_started.set()
await asyncio.wait_for(both_started.wait(), timeout=1.0)
execution_order.append(f"{name}_end")
return f"{name} done"
call_count = 0
def sampling_handler(
messages: list[SamplingMessage], params: SamplingParams, ctx: RequestContext
) -> CreateMessageResultWithTools:
nonlocal call_count
call_count += 1
if call_count == 1:
# Request 3 tools (with concurrency=2, first 2 run parallel, then 3rd)
return CreateMessageResultWithTools(
role="assistant",
content=[
ToolUseContent(
type="tool_use",
id="call_1",
name="slow_tool",
input={"name": "tool_1"},
),
ToolUseContent(
type="tool_use",
id="call_2",
name="slow_tool",
input={"name": "tool_2"},
),
ToolUseContent(
type="tool_use",
id="call_3",
name="slow_tool",
input={"name": "tool_3"},
),
],
model="test-model",
stop_reason="toolUse",
)
else:
return CreateMessageResultWithTools(
role="assistant",
content=[TextContent(type="text", text="Done!")],
model="test-model",
stop_reason="endTurn",
)
mcp = FastMCP(sampling_handler=sampling_handler)
@mcp.tool
async def test_tool(context: Context) -> str:
result = await context.sample(
messages="Run tools",
tools=[slow_tool],
tool_concurrency=2, # Max 2 concurrent
)
return result.text or ""
async with Client(mcp) as client:
result = await client.call_tool("test_tool", {})
assert result.data == "Done!"
# Verify that at most 2 tools run concurrently
# First 2 tools should start before either ends
assert execution_order[0] in ["tool_1_start", "tool_2_start"]
assert execution_order[1] in ["tool_1_start", "tool_2_start"]
# Third tool should start after at least one of the first two finishes
tool_3_start_idx = execution_order.index("tool_3_start")
assert (
"tool_1_end" in execution_order[:tool_3_start_idx]
or "tool_2_end" in execution_order[:tool_3_start_idx]
)
async def test_sequential_tool_forces_sequential_execution(self):
"""Test that sequential=True forces all tools to execute sequentially."""
import asyncio
from mcp_types import CreateMessageResultWithTools, ToolUseContent
# A sequential=True tool in the batch forces the whole batch through
# the plain for-loop path (see run.py's `requires_sequential`), so
# ordering is guaranteed structurally and no real delay is needed.
execution_order: list[str] = []
async def normal_tool(x: int) -> int:
"""Normal tool."""
execution_order.append("normal_start")
await asyncio.sleep(0)
execution_order.append("normal_end")
return x * 2
async def sequential_tool(y: int) -> int:
"""Sequential tool."""
execution_order.append("sequential_start")
await asyncio.sleep(0)
execution_order.append("sequential_end")
return y + 10
call_count = 0
def sampling_handler(
messages: list[SamplingMessage], params: SamplingParams, ctx: RequestContext
) -> CreateMessageResultWithTools:
nonlocal call_count
call_count += 1
if call_count == 1:
return CreateMessageResultWithTools(
role="assistant",
content=[
ToolUseContent(
type="tool_use",
id="call_1",
name="normal_tool",
input={"x": 5},
),
ToolUseContent(
type="tool_use",
id="call_2",
name="sequential_tool",
input={"y": 3},
),
],
model="test-model",
stop_reason="toolUse",
)
else:
return CreateMessageResultWithTools(
role="assistant",
content=[TextContent(type="text", text="Done!")],
model="test-model",
stop_reason="endTurn",
)
mcp = FastMCP(sampling_handler=sampling_handler)
@mcp.tool
async def test_tool(context: Context) -> str:
# Create tools with sequential=True for one of them
normal = SamplingTool.from_function(normal_tool, sequential=False)
sequential = SamplingTool.from_function(sequential_tool, sequential=True)
result = await context.sample(
messages="Run tools",
tools=[normal, sequential],
tool_concurrency=0, # Request unlimited, but sequential tool forces sequential
)
return result.text or ""
async with Client(mcp) as client:
result = await client.call_tool("test_tool", {})
assert result.data == "Done!"
# Verify sequential execution: first tool must complete before second starts
assert execution_order[0] in ["normal_start", "sequential_start"]
assert execution_order[1] in ["normal_end", "sequential_end"]
# Ensure the second tool starts after the first ends
if execution_order[0] == "normal_start":
assert execution_order[1] == "normal_end"
assert execution_order[2] == "sequential_start"
else:
assert execution_order[1] == "sequential_end"
assert execution_order[2] == "normal_start"
async def test_concurrent_tool_execution_error_handling(self):
"""Test that errors are captured per-tool in parallel execution."""
from mcp_types import (
CreateMessageResultWithTools,
ToolResultContent,
ToolUseContent,
)
def good_tool() -> str:
return "success"
def bad_tool() -> str:
raise ValueError("Tool error")
messages_received: list[list[SamplingMessage]] = []
def sampling_handler(
messages: list[SamplingMessage], params: SamplingParams, ctx: RequestContext
) -> CreateMessageResultWithTools:
messages_received.append(list(messages))
if len(messages_received) == 1:
return CreateMessageResultWithTools(
role="assistant",
content=[
ToolUseContent(
type="tool_use", id="call_1", name="good_tool", input={}
),
ToolUseContent(
type="tool_use", id="call_2", name="bad_tool", input={}
),
],
model="test-model",
stop_reason="toolUse",
)
else:
return CreateMessageResultWithTools(
role="assistant",
content=[TextContent(type="text", text="Handled errors")],
model="test-model",
stop_reason="endTurn",
)
mcp = FastMCP(sampling_handler=sampling_handler)
@mcp.tool
async def test_tool(context: Context) -> str:
result = await context.sample(
messages="Run tools",
tools=[good_tool, bad_tool],
tool_concurrency=0, # Parallel execution
)
return result.text or ""
async with Client(mcp) as client:
result = await client.call_tool("test_tool", {})
assert result.data == "Handled errors"
# Check that tool results include both success and error
tool_result_message = messages_received[1][-1]
assert tool_result_message.role == "user"
tool_results = cast(list[ToolResultContent], tool_result_message.content)
assert len(tool_results) == 2
# One should be success, one should be error
assert any(not r.is_error for r in tool_results)
assert any(r.is_error for r in tool_results)
async def test_concurrent_tool_result_order_preserved(self):
"""Test that tool results maintain the same order as tool calls."""
import asyncio
from mcp_types import (
CreateMessageResultWithTools,
ToolResultContent,
ToolUseContent,
)
# Chain events so the tools finish in a different order (2, 3, 1)
# than they were called (1, 2, 3), without depending on real delays:
# tool 2 finishes immediately and unblocks tool 3, which finishes and
# unblocks tool 1. This only resolves if all three run concurrently -
# under sequential execution tool 1 would deadlock waiting on tool 3,
# which itself would never have been started yet.
tool_2_done = asyncio.Event()
tool_3_done = asyncio.Event()
async def tool_with_delay(value: int) -> int:
"""Tool that finishes out of call order."""
if value == 1:
await asyncio.wait_for(tool_3_done.wait(), timeout=1.0)
elif value == 3:
await asyncio.wait_for(tool_2_done.wait(), timeout=1.0)
if value == 2:
tool_2_done.set()
elif value == 3:
tool_3_done.set()
return value
messages_received: list[list[SamplingMessage]] = []
def sampling_handler(
messages: list[SamplingMessage], params: SamplingParams, ctx: RequestContext
) -> CreateMessageResultWithTools:
messages_received.append(list(messages))
if len(messages_received) == 1:
# Call order is 1, 2, 3 but they finish out of order (2, 3, 1)
return CreateMessageResultWithTools(
role="assistant",
content=[
ToolUseContent(
type="tool_use",
id="call_1",
name="tool_with_delay",
input={"value": 1},
),
ToolUseContent(
type="tool_use",
id="call_2",
name="tool_with_delay",
input={"value": 2},
),
ToolUseContent(
type="tool_use",
id="call_3",
name="tool_with_delay",
input={"value": 3},
),
],
model="test-model",
stop_reason="toolUse",
)
else:
return CreateMessageResultWithTools(
role="assistant",
content=[TextContent(type="text", text="Done!")],
model="test-model",
stop_reason="endTurn",
)
mcp = FastMCP(sampling_handler=sampling_handler)
@mcp.tool
async def test_tool(context: Context) -> str:
result = await context.sample(
messages="Run tools",
tools=[tool_with_delay],
tool_concurrency=0, # Parallel execution
)
return result.text or ""
async with Client(mcp) as client:
result = await client.call_tool("test_tool", {})
assert result.data == "Done!"
# Check that results are in the correct order (1, 2, 3) despite finishing order (2, 3, 1)
tool_result_message = messages_received[1][-1]
tool_results = cast(list[ToolResultContent], tool_result_message.content)
assert len(tool_results) == 3
assert tool_results[0].tool_use_id == "call_1"
assert tool_results[1].tool_use_id == "call_2"
assert tool_results[2].tool_use_id == "call_3"
# Check values are correct
result_texts = [cast(TextContent, r.content[0]).text for r in tool_results]
assert result_texts == ["1", "2", "3"]

View file

@ -175,12 +175,27 @@ async def test_tool_with_progress(ctx: Context) -> str:
@server.tool(name="test_sampling")
async def test_sampling(prompt: str, ctx: Context) -> str:
"""Requests LLM sampling via the client."""
result = await ctx.sample(
messages=[prompt],
result_type=str,
"""Requests LLM sampling via the client.
`Context` has no `sample()` server-initiated sampling is not part of
FastMCP's server API. The handshake-era wire path is still supported and
still shipped (the proxy relay uses it), so this fixture reaches the SDK
session directly to keep the scenario covered.
"""
result = await ctx.session.create_message( # ty: ignore[deprecated]
messages=[
mcp_types.SamplingMessage(
role="user",
content=mcp_types.TextContent(type="text", text=prompt),
)
],
max_tokens=512,
related_request_id=ctx.origin_request_id,
)
return f"Sampling result: {result}"
text = (
result.content.text if isinstance(result.content, mcp_types.TextContent) else ""
)
return f"Sampling result: {text}"
class _UserInfo(BaseModel):

View file

@ -147,10 +147,6 @@ def mcp_server(recording_middleware):
async def log_tool(context: Context) -> None:
await context.info(message="test log")
@mcp.tool
async def sample_tool(context: Context) -> None:
await context.sample("hello")
mcp.add_middleware(recording_middleware)
# Register a progress notification handler (v2 API: (ctx, params)).

View file

@ -149,10 +149,6 @@ def mcp_server(recording_middleware):
async def log_tool(context: Context) -> None:
await context.info(message="test log")
@mcp.tool
async def sample_tool(context: Context) -> None:
await context.sample("hello")
mcp.add_middleware(recording_middleware)
# Register a progress notification handler (v2 API: (ctx, params)).
@ -205,10 +201,6 @@ class TestNestedMiddlewareHooks:
async def log_tool(context: Context) -> None:
await context.info(message="test log")
@mcp.tool
async def sample_tool(context: Context) -> None:
await context.sample("hello")
mcp.add_middleware(nested_middleware)
return mcp
@ -510,7 +502,7 @@ class TestProxyServer:
async with Client(proxy_server) as client:
await client.list_tools()
assert TAGS == [{"add-tool"}, set(), set(), set()]
assert TAGS == [{"add-tool"}, set(), set()]
class TestToolCallDenial:

View file

@ -8,6 +8,7 @@ from mcp_types import (
LoggingLevel,
ModelHint,
ModelPreferences,
Root,
TextContent,
)
from pydantic import BaseModel, Field
@ -58,6 +59,34 @@ class TestProxyClientEraDefault:
assert cast(Client, factory()).mode == "auto"
async def _backend_list_roots(context: Context) -> list[Root]:
"""Issue a handshake-era `roots/list` from a backend server.
`Context` has no `list_roots()`: server-initiated requests are not part of
FastMCP's server API. These helpers reach the SDK session directly to stand
in for a legacy upstream, which is the only thing the proxy relay forwards.
"""
result = await context.session.list_roots() # ty: ignore[deprecated]
return result.roots
async def _backend_sample(context: Context) -> str:
"""Issue a handshake-era `sampling/createMessage` from a backend server."""
result = await context.session.create_message( # ty: ignore[deprecated]
messages=[
SamplingMessage(
role="user", content=TextContent(type="text", text="Hello, world!")
)
],
system_prompt="You love FastMCP",
temperature=0.5,
max_tokens=100,
model_preferences=ModelPreferences(hints=[ModelHint(name="gpt-4o")]),
related_request_id=context.origin_request_id,
)
return result.content.text if isinstance(result.content, TextContent) else ""
@pytest.fixture
def fastmcp_server():
mcp = FastMCP("TestServer")
@ -68,21 +97,13 @@ def fastmcp_server():
@mcp.tool
async def list_roots(context: Context) -> list[str]:
roots = await context.list_roots()
return [str(r.uri) for r in roots]
return [str(r.uri) for r in await _backend_list_roots(context)]
@mcp.tool
async def sampling(
context: Context,
) -> str:
result = await context.sample(
"Hello, world!",
system_prompt="You love FastMCP",
temperature=0.5,
max_tokens=100,
model_preferences="gpt-4o",
)
return result.text or ""
return await _backend_sample(context)
@dataclass
class Person:
@ -514,17 +535,16 @@ def roots_backend_server():
@mcp.resource("data://roots")
async def roots_resource(context: Context) -> list[str]:
roots = await context.list_roots()
return [str(r.uri) for r in roots]
return [str(r.uri) for r in await _backend_list_roots(context)]
@mcp.resource("data://roots/{key}")
async def roots_template(key: str, context: Context) -> str:
roots = await context.list_roots()
roots = await _backend_list_roots(context)
return ", ".join(f"{key}:{r.uri}" for r in roots)
@mcp.prompt
async def roots_prompt(context: Context) -> str:
roots = await context.list_roots()
roots = await _backend_list_roots(context)
return ", ".join(str(r.uri) for r in roots)
return mcp

View file

@ -1,111 +0,0 @@
"""Tests for prepare_tools helper function."""
import pytest
from fastmcp.server.sampling.run import prepare_tools
from fastmcp.server.sampling.sampling_tool import SamplingTool
from fastmcp.tools.function_tool import FunctionTool
from fastmcp.tools.tool_transform import ArgTransform, TransformedTool
class TestPrepareTools:
"""Tests for prepare_tools()."""
def test_prepare_tools_with_none(self):
"""Test that None returns None."""
result = prepare_tools(None)
assert result is None
def test_prepare_tools_with_sampling_tool(self):
"""Test that SamplingTool instances pass through."""
def search(query: str) -> str:
return f"Results: {query}"
sampling_tool = SamplingTool.from_function(search)
result = prepare_tools([sampling_tool])
assert result is not None
assert len(result) == 1
assert result[0] is sampling_tool
def test_prepare_tools_with_function(self):
"""Test that plain functions are converted."""
def search(query: str) -> str:
"""Search function."""
return f"Results: {query}"
result = prepare_tools([search])
assert result is not None
assert len(result) == 1
assert isinstance(result[0], SamplingTool)
assert result[0].name == "search"
def test_prepare_tools_with_function_tool(self):
"""Test that FunctionTool instances are converted."""
def search(query: str) -> str:
"""Search the web."""
return f"Results: {query}"
function_tool = FunctionTool.from_function(search)
result = prepare_tools([function_tool])
assert result is not None
assert len(result) == 1
assert isinstance(result[0], SamplingTool)
assert result[0].name == "search"
assert result[0].description == "Search the web."
def test_prepare_tools_with_transformed_tool(self):
"""Test that TransformedTool instances are converted."""
def original(query: str) -> str:
"""Original tool."""
return f"Results: {query}"
function_tool = FunctionTool.from_function(original)
transformed_tool = TransformedTool.from_tool(
function_tool,
name="search_v2",
transform_args={"query": ArgTransform(name="q")},
)
result = prepare_tools([transformed_tool])
assert result is not None
assert len(result) == 1
assert isinstance(result[0], SamplingTool)
assert result[0].name == "search_v2"
assert "q" in result[0].parameters.get("properties", {})
def test_prepare_tools_with_mixed_types(self):
"""Test that mixed tool types are all converted."""
def plain_fn(x: int) -> int:
return x * 2
def fn_for_tool(y: int) -> int:
return y * 3
function_tool = FunctionTool.from_function(fn_for_tool)
sampling_tool = SamplingTool.from_function(lambda z: z * 4, name="lambda_tool")
result = prepare_tools([plain_fn, function_tool, sampling_tool])
assert result is not None
assert len(result) == 3
assert all(isinstance(t, SamplingTool) for t in result)
def test_prepare_tools_with_invalid_type(self):
"""Test that invalid types raise TypeError."""
with pytest.raises(TypeError, match="Expected SamplingTool, FunctionTool"):
prepare_tools(["not a tool"]) # type: ignore[arg-type] # ty:ignore[invalid-argument-type]
def test_prepare_tools_empty_list(self):
"""Test that empty list returns None."""
result = prepare_tools([])
assert result is None

View file

@ -1,440 +0,0 @@
"""Tests for SamplingTool."""
import pytest
from mcp.server.auth.middleware.auth_context import auth_context_var
from mcp.server.auth.middleware.bearer_auth import AuthenticatedUser
from fastmcp.exceptions import AuthorizationError
from fastmcp.server.auth import AccessToken, require_scopes
from fastmcp.server.context import _current_transport
from fastmcp.server.sampling import SamplingTool
from fastmcp.tools.function_tool import FunctionTool
from fastmcp.tools.tool_transform import ArgTransform, TransformedTool
class TestSamplingToolFromFunction:
"""Tests for SamplingTool.from_function()."""
def test_from_simple_function(self):
def search(query: str) -> str:
"""Search the web."""
return f"Results for: {query}"
tool = SamplingTool.from_function(search)
assert tool.name == "search"
assert tool.description == "Search the web."
assert "query" in tool.parameters.get("properties", {})
assert tool.fn is search
def test_from_function_with_overrides(self):
def search(query: str) -> str:
return f"Results for: {query}"
tool = SamplingTool.from_function(
search,
name="web_search",
description="Search the internet",
)
assert tool.name == "web_search"
assert tool.description == "Search the internet"
def test_from_lambda_requires_name(self):
with pytest.raises(ValueError, match="must provide a name for lambda"):
SamplingTool.from_function(lambda x: x)
def test_from_lambda_with_name(self):
tool = SamplingTool.from_function(lambda x: x * 2, name="double")
assert tool.name == "double"
def test_from_async_function(self):
async def async_search(query: str) -> str:
"""Async search."""
return f"Async results for: {query}"
tool = SamplingTool.from_function(async_search)
assert tool.name == "async_search"
assert tool.description == "Async search."
def test_multiple_parameters(self):
def search(query: str, limit: int = 10, include_images: bool = False) -> str:
"""Search with options."""
return f"Results for: {query}"
tool = SamplingTool.from_function(search)
props = tool.parameters.get("properties", {})
assert "query" in props
assert "limit" in props
assert "include_images" in props
class TestSamplingToolRun:
"""Tests for SamplingTool.run()."""
async def test_run_sync_function(self):
def add(a: int, b: int) -> int:
"""Add two numbers."""
return a + b
tool = SamplingTool.from_function(add)
result = await tool.run({"a": 2, "b": 3})
assert result == 5
async def test_run_async_function(self):
async def async_add(a: int, b: int) -> int:
"""Add two numbers asynchronously."""
return a + b
tool = SamplingTool.from_function(async_add)
result = await tool.run({"a": 2, "b": 3})
assert result == 5
async def test_run_with_no_arguments(self):
def get_value() -> str:
"""Return a fixed value."""
return "hello"
tool = SamplingTool.from_function(get_value)
result = await tool.run()
assert result == "hello"
async def test_run_with_none_arguments(self):
def get_value() -> str:
"""Return a fixed value."""
return "hello"
tool = SamplingTool.from_function(get_value)
result = await tool.run(None)
assert result == "hello"
class TestSamplingToolSDKConversion:
"""Tests for SamplingTool._to_sdk_tool() internal method."""
def test_to_sdk_tool(self):
def search(query: str) -> str:
"""Search the web."""
return f"Results for: {query}"
tool = SamplingTool.from_function(search)
sdk_tool = tool._to_sdk_tool()
assert sdk_tool.name == "search"
assert sdk_tool.description == "Search the web."
assert "query" in sdk_tool.input_schema.get("properties", {})
class TestSamplingToolFromCallableTool:
"""Tests for SamplingTool.from_callable_tool()."""
def test_from_function_tool(self):
"""Test converting a FunctionTool to SamplingTool."""
def search(query: str) -> str:
"""Search the web."""
return f"Results for: {query}"
function_tool = FunctionTool.from_function(search)
sampling_tool = SamplingTool.from_callable_tool(function_tool)
assert sampling_tool.name == "search"
assert sampling_tool.description == "Search the web."
assert "query" in sampling_tool.parameters.get("properties", {})
# fn is now a wrapper that calls tool.run() for proper result processing
assert callable(sampling_tool.fn)
def test_from_function_tool_with_overrides(self):
"""Test converting FunctionTool with name/description overrides."""
def search(query: str) -> str:
"""Search the web."""
return f"Results for: {query}"
function_tool = FunctionTool.from_function(search)
sampling_tool = SamplingTool.from_callable_tool(
function_tool,
name="web_search",
description="Search the internet",
)
assert sampling_tool.name == "web_search"
assert sampling_tool.description == "Search the internet"
def test_from_transformed_tool(self):
"""Test converting a TransformedTool to SamplingTool."""
def original(query: str, limit: int) -> str:
"""Original tool."""
return f"Results for: {query} (limit: {limit})"
function_tool = FunctionTool.from_function(original)
transformed_tool = TransformedTool.from_tool(
function_tool,
name="search_transformed",
transform_args={"query": ArgTransform(name="q")},
)
sampling_tool = SamplingTool.from_callable_tool(transformed_tool)
assert sampling_tool.name == "search_transformed"
assert sampling_tool.description == "Original tool."
# The transformed tool should have 'q' instead of 'query'
assert "q" in sampling_tool.parameters.get("properties", {})
assert "limit" in sampling_tool.parameters.get("properties", {})
async def test_from_function_tool_execution(self):
"""Test that converted FunctionTool executes correctly."""
def add(a: int, b: int) -> int:
"""Add two numbers."""
return a + b
function_tool = FunctionTool.from_function(add)
sampling_tool = SamplingTool.from_callable_tool(function_tool)
result = await sampling_tool.run({"a": 2, "b": 3})
assert result == 5
async def test_from_transformed_tool_execution(self):
"""Test that converted TransformedTool executes correctly."""
def multiply(x: int, y: int) -> int:
"""Multiply two numbers."""
return x * y
function_tool = FunctionTool.from_function(multiply)
transformed_tool = TransformedTool.from_tool(
function_tool,
transform_args={"x": ArgTransform(name="a"), "y": ArgTransform(name="b")},
)
sampling_tool = SamplingTool.from_callable_tool(transformed_tool)
# Use the transformed parameter names
result = await sampling_tool.run({"a": 3, "b": 4})
# Result should be unwrapped from ToolResult
assert result == 12
def test_from_invalid_tool_type(self):
"""Test that from_callable_tool rejects non-tool objects."""
class NotATool:
pass
with pytest.raises(
TypeError,
match="Expected FunctionTool or TransformedTool",
):
SamplingTool.from_callable_tool(NotATool()) # type: ignore[arg-type] # ty:ignore[invalid-argument-type]
def test_from_plain_function_fails(self):
"""Test that plain functions are rejected by from_callable_tool."""
def my_function():
pass
with pytest.raises(TypeError, match="Expected FunctionTool or TransformedTool"):
SamplingTool.from_callable_tool(my_function) # type: ignore[arg-type] # ty:ignore[invalid-argument-type]
async def test_from_function_tool_with_output_schema(self):
"""Test that FunctionTool with output_schema is handled correctly."""
def search(query: str) -> dict:
"""Search for something."""
return {"results": ["item1", "item2"], "count": 2}
# Create FunctionTool with x-fastmcp-wrap-result
function_tool = FunctionTool.from_function(
search,
output_schema={
"type": "object",
"properties": {
"results": {"type": "array"},
"count": {"type": "integer"},
},
"x-fastmcp-wrap-result": True,
},
)
sampling_tool = SamplingTool.from_callable_tool(function_tool)
# Run the tool - should unwrap the {"result": {...}} wrapper
result = await sampling_tool.run({"query": "test"})
# Should get the unwrapped dict, not ToolResult
assert isinstance(result, dict)
assert result == {"results": ["item1", "item2"], "count": 2}
async def test_from_function_tool_without_wrap_result(self):
"""Test that FunctionTool without x-fastmcp-wrap-result is handled correctly."""
def get_data() -> dict:
"""Get some data."""
return {"status": "ok", "value": 42}
# Create FunctionTool with output_schema but no wrap-result flag
function_tool = FunctionTool.from_function(
get_data,
output_schema={
"type": "object",
"properties": {
"status": {"type": "string"},
"value": {"type": "integer"},
},
},
)
sampling_tool = SamplingTool.from_callable_tool(function_tool)
# Run the tool - should return structured_content directly
result = await sampling_tool.run({})
assert isinstance(result, dict)
assert result == {"status": "ok", "value": 42}
class TestSamplingToolAuthEnforcement:
"""Tests that auth-protected tools enforce auth when used via sampling."""
async def test_auth_protected_tool_blocked_without_token(self):
"""An auth-protected tool wrapped as SamplingTool must reject
calls when no valid token is present in a non-stdio transport."""
def secret_action() -> str:
"""Do something privileged."""
return "secret"
function_tool = FunctionTool.from_function(
secret_action,
auth=require_scopes("admin"),
)
sampling_tool = SamplingTool.from_callable_tool(function_tool)
transport_token = _current_transport.set("streamable-http")
try:
with pytest.raises(AuthorizationError, match="insufficient permissions"):
await sampling_tool.run({})
finally:
_current_transport.reset(transport_token)
async def test_auth_protected_tool_blocked_with_wrong_scopes(self):
"""An auth-protected tool rejects calls when the token lacks
the required scopes."""
def secret_action() -> str:
"""Do something privileged."""
return "secret"
function_tool = FunctionTool.from_function(
secret_action,
auth=require_scopes("admin"),
)
sampling_tool = SamplingTool.from_callable_tool(function_tool)
token = AccessToken(
token="test",
client_id="c",
scopes=["read"],
expires_at=None,
claims={},
)
transport_token = _current_transport.set("streamable-http")
auth_token = auth_context_var.set(AuthenticatedUser(token))
try:
with pytest.raises(AuthorizationError, match="insufficient permissions"):
await sampling_tool.run({})
finally:
auth_context_var.reset(auth_token)
_current_transport.reset(transport_token)
async def test_auth_protected_tool_allowed_with_correct_scopes(self):
"""An auth-protected tool succeeds when the token has the
required scopes."""
def secret_action() -> str:
"""Do something privileged."""
return "secret"
function_tool = FunctionTool.from_function(
secret_action,
auth=require_scopes("admin"),
)
sampling_tool = SamplingTool.from_callable_tool(function_tool)
token = AccessToken(
token="test",
client_id="c",
scopes=["admin"],
expires_at=None,
claims={},
)
transport_token = _current_transport.set("streamable-http")
auth_token = auth_context_var.set(AuthenticatedUser(token))
try:
result = await sampling_tool.run({})
assert result == "secret"
finally:
auth_context_var.reset(auth_token)
_current_transport.reset(transport_token)
async def test_auth_protected_tool_skipped_on_stdio(self):
"""Auth checks are skipped for stdio transport, matching
server dispatcher behavior."""
def secret_action() -> str:
"""Do something privileged."""
return "secret"
function_tool = FunctionTool.from_function(
secret_action,
auth=require_scopes("admin"),
)
sampling_tool = SamplingTool.from_callable_tool(function_tool)
transport_token = _current_transport.set("stdio")
try:
result = await sampling_tool.run({})
assert result == "secret"
finally:
_current_transport.reset(transport_token)
async def test_tool_without_auth_runs_normally(self):
"""Tools without auth still run without any auth context."""
def public_action() -> str:
"""Do something public."""
return "public"
function_tool = FunctionTool.from_function(public_action)
sampling_tool = SamplingTool.from_callable_tool(function_tool)
result = await sampling_tool.run({})
assert result == "public"
async def test_auth_protected_transformed_tool_blocked(self):
"""Auth checks also apply to TransformedTools with auth."""
def secret_action(x: int) -> int:
"""Privileged computation."""
return x * 2
function_tool = FunctionTool.from_function(
secret_action,
auth=require_scopes("compute"),
)
transformed_tool = TransformedTool.from_tool(
function_tool,
transform_args={"x": ArgTransform(name="value")},
)
sampling_tool = SamplingTool.from_callable_tool(transformed_tool)
transport_token = _current_transport.set("streamable-http")
try:
with pytest.raises(AuthorizationError, match="insufficient permissions"):
await sampling_tool.run({"value": 5})
finally:
_current_transport.reset(transport_token)

View file

@ -1,881 +0,0 @@
"""Tracing coverage for sampling create_message and tool-execution spans.
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
import pytest
from mcp_types import TextContent
from opentelemetry.context import Context as OTelContext
from opentelemetry.sdk.trace import (
ReadableSpan,
Span,
SpanLimits,
SpanProcessor,
TracerProvider,
)
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter
from opentelemetry.sdk.trace.sampling import Decision, Sampler, SamplingResult
from opentelemetry.trace import StatusCode
from opentelemetry.util import types as otel_types
from fastmcp import Client, Context, FastMCP
from fastmcp.client.sampling import RequestContext, SamplingMessage, SamplingParams
class OnStartRecorder(SpanProcessor):
def __init__(self) -> None:
self.attributes: dict[str, dict[str, object]] = {}
def on_start(self, span: Span, parent_context: OTelContext | None = None) -> None:
self.attributes[span.name] = dict(span.attributes or {})
class NonForwardingSampler(Sampler):
"""Samples every span but never forwards the attributes it was handed.
See `tests/telemetry/test_span_attributes.py` for the full explanation:
OTel's `Tracer.start_span` builds the finished span from
`sampling_result.attributes`, not from the `attributes` kwarg passed to
`start_as_current_span`, so a custom sampler like this one reproduces the
regression where a non-forwarding sampler silently drops FastMCP's
attributes.
"""
def should_sample(
self,
parent_context: OTelContext | None,
trace_id: int,
name: str,
kind: object = None,
attributes: object = None,
links: object = None,
trace_state: object = None,
) -> SamplingResult:
return SamplingResult(Decision.RECORD_AND_SAMPLE)
def get_description(self) -> str:
return "NonForwardingSampler"
class RedactingSampler(Sampler):
"""Forwards the attributes it receives, but replaces `mcp.method.name`.
See `tests/telemetry/test_span_attributes.py` for the full explanation:
a sampler can legitimately keep an attribute FastMCP set while replacing
its value (e.g. redacting the method name for privacy), and that decision
must survive the restore step.
"""
def should_sample(
self,
parent_context: OTelContext | None,
trace_id: int,
name: str,
kind: object = None,
attributes: otel_types.Attributes = None,
links: object = None,
trace_state: object = None,
) -> SamplingResult:
forwarded = dict(attributes or {})
forwarded["mcp.method.name"] = "REDACTED"
return SamplingResult(Decision.RECORD_AND_SAMPLE, attributes=forwarded)
def get_description(self) -> str:
return "RedactingSampler"
class AttributeAddingSampler(Sampler):
"""Forwards the attributes it receives unchanged and adds its own."""
def should_sample(
self,
parent_context: OTelContext | None,
trace_id: int,
name: str,
kind: object = None,
attributes: otel_types.Attributes = None,
links: object = None,
trace_state: object = None,
) -> SamplingResult:
forwarded = dict(attributes or {})
forwarded["sampling.policy"] = "always_on"
return SamplingResult(Decision.RECORD_AND_SAMPLE, attributes=forwarded)
def get_description(self) -> str:
return "AttributeAddingSampler"
class FilteringSampler(Sampler):
"""Discards every attribute it receives and substitutes its own.
See `tests/telemetry/test_span_attributes.py` for the full explanation:
a sampler can legitimately supply only its own attributes (e.g. to strip
component names or resource URIs for privacy or cardinality control),
and the restore step must not reintroduce FastMCP's attributes in that
case doing so would defeat the filter.
"""
def should_sample(
self,
parent_context: OTelContext | None,
trace_id: int,
name: str,
kind: object = None,
attributes: otel_types.Attributes = None,
links: object = None,
trace_state: object = None,
) -> SamplingResult:
return SamplingResult(
Decision.RECORD_AND_SAMPLE, attributes={"sampling.policy": "filtered"}
)
def get_description(self) -> str:
return "FilteringSampler"
@pytest.fixture
def on_start_recorder(
monkeypatch: pytest.MonkeyPatch,
trace_exporter: InMemorySpanExporter,
) -> OnStartRecorder:
recorder = OnStartRecorder()
provider = TracerProvider()
provider.add_span_processor(recorder)
provider.add_span_processor(SimpleSpanProcessor(trace_exporter))
tracer = provider.get_tracer("test")
monkeypatch.setattr("fastmcp.server.sampling.run.get_tracer", lambda: tracer)
return recorder
def _spans_named(exporter: InMemorySpanExporter, name: str):
return [s for s in exporter.get_finished_spans() if s.name == name]
def _exception_events(span):
return [e for e in span.events if e.name == "exception"]
class TestSamplingCreateMessageSpan:
async def test_success_creates_span_with_attributes(
self,
trace_exporter: InMemorySpanExporter,
on_start_recorder: OnStartRecorder,
):
def sampling_handler(
messages: list[SamplingMessage],
params: SamplingParams,
ctx: RequestContext,
) -> str:
return "sampled-text"
mcp = FastMCP("sampling-server")
@mcp.tool
async def ask(question: str, context: Context) -> str:
result = await context.sample(messages=question)
return result.text or ""
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")
assert len(spans) == 1
span = spans[0]
assert span.attributes is not None
assert span.attributes["mcp.method.name"] == "sampling/createMessage"
assert span.attributes["fastmcp.server.name"] == "sampling-server"
assert on_start_recorder.attributes["sampling create_message"] == {
"mcp.method.name": "sampling/createMessage",
"fastmcp.server.name": "sampling-server",
}
# Success path must not record any exception.
assert _exception_events(span) == []
assert span.status.status_code != StatusCode.ERROR
async def test_failure_records_exception_exactly_once(
self, trace_exporter: InMemorySpanExporter
):
"""Regression: span created with record_exception=False so the manual
record_exception in the except block fires exactly once (no duplicate
exception events from OTel auto-recording on `with` exit)."""
def sampling_handler(
messages: list[SamplingMessage],
params: SamplingParams,
ctx: RequestContext,
) -> str:
raise RuntimeError("sampling boom")
mcp = FastMCP("sampling-server")
@mcp.tool
async def ask(question: str, context: Context) -> str:
result = await context.sample(messages=question)
return result.text or ""
with pytest.raises(Exception):
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")
assert len(spans) == 1
span = spans[0]
assert span.status.status_code == StatusCode.ERROR
assert span.attributes is not None
assert "error.type" in span.attributes
# The whole point of the fix: exactly one exception event.
assert len(_exception_events(span)) == 1
class TestSamplingToolSpan:
async def test_tool_error_span_records_exception_once(
self,
trace_exporter: InMemorySpanExporter,
on_start_recorder: OnStartRecorder,
):
from mcp_types import CreateMessageResultWithTools, ToolUseContent
call_count = 0
def boom_tool() -> str:
raise ValueError("tool exploded")
def sampling_handler(
messages: list[SamplingMessage],
params: SamplingParams,
ctx: RequestContext,
) -> CreateMessageResultWithTools:
nonlocal call_count
call_count += 1
if call_count == 1:
return CreateMessageResultWithTools(
role="assistant",
content=[
ToolUseContent(
type="tool_use",
id="call_1",
name="boom_tool",
input={},
)
],
model="test-model",
stop_reason="toolUse",
)
return CreateMessageResultWithTools(
role="assistant",
content=[TextContent(type="text", text="done")],
model="test-model",
stop_reason="endTurn",
)
mcp = FastMCP(sampling_handler=sampling_handler)
@mcp.tool
async def driver(context: Context) -> str:
result = await context.sample(messages="go", tools=[boom_tool])
return result.text or ""
async with Client(mcp) as client:
await client.call_tool("driver", {})
spans = _spans_named(trace_exporter, "sampling tool boom_tool")
assert len(spans) == 1
span = spans[0]
assert span.status.status_code == StatusCode.ERROR
assert span.attributes is not None
assert span.attributes["gen_ai.tool.name"] == "boom_tool"
assert on_start_recorder.attributes["sampling tool boom_tool"] == {
"gen_ai.tool.name": "boom_tool",
"fastmcp.tool.use_id": "call_1",
}
assert "error.type" in span.attributes
# Tool spans catch-and-convert (no re-raise), so OTel auto-recording
# never fires; the manual record_exception must fire exactly once.
assert len(_exception_events(span)) == 1
class TestAttributesSurviveANonForwardingSampler:
"""Regression: `sampling create_message` and `sampling tool ...` spans
must keep FastMCP's attributes even when the configured Sampler doesn't
forward the `attributes` it was handed to its `SamplingResult`.
"""
@pytest.fixture
def non_forwarding_recorder(
self,
monkeypatch: pytest.MonkeyPatch,
trace_exporter: InMemorySpanExporter,
) -> OnStartRecorder:
recorder = OnStartRecorder()
provider = TracerProvider(sampler=NonForwardingSampler())
provider.add_span_processor(recorder)
provider.add_span_processor(SimpleSpanProcessor(trace_exporter))
tracer = provider.get_tracer("test")
monkeypatch.setattr("fastmcp.server.sampling.run.get_tracer", lambda: tracer)
return recorder
async def test_create_message_span_keeps_attributes(
self,
trace_exporter: InMemorySpanExporter,
non_forwarding_recorder: OnStartRecorder,
):
def sampling_handler(
messages: list[SamplingMessage],
params: SamplingParams,
ctx: RequestContext,
) -> str:
return "sampled-text"
mcp = FastMCP("sampling-server")
@mcp.tool
async def ask(question: str, context: Context) -> str:
result = await context.sample(messages=question)
return result.text or ""
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")
assert len(spans) == 1
span = spans[0]
assert span.attributes is not None
assert span.attributes["mcp.method.name"] == "sampling/createMessage"
assert span.attributes["fastmcp.server.name"] == "sampling-server"
# The sampler never forwards attributes, so on_start legitimately sees
# none — this documents that limitation rather than asserting around it.
assert non_forwarding_recorder.attributes["sampling create_message"] == {}
async def test_sampling_tool_span_keeps_attributes(
self,
trace_exporter: InMemorySpanExporter,
non_forwarding_recorder: OnStartRecorder,
):
from mcp_types import CreateMessageResultWithTools, ToolUseContent
def echo_tool(text: str) -> str:
return text
call_count = 0
def sampling_handler(
messages: list[SamplingMessage],
params: SamplingParams,
ctx: RequestContext,
) -> CreateMessageResultWithTools:
nonlocal call_count
call_count += 1
if call_count == 1:
return CreateMessageResultWithTools(
role="assistant",
content=[
ToolUseContent(
type="tool_use",
id="call_1",
name="echo_tool",
input={"text": "hi"},
)
],
model="test-model",
stop_reason="toolUse",
)
return CreateMessageResultWithTools(
role="assistant",
content=[TextContent(type="text", text="done")],
model="test-model",
stop_reason="endTurn",
)
mcp = FastMCP(sampling_handler=sampling_handler)
@mcp.tool
async def driver(context: Context) -> str:
result = await context.sample(messages="go", tools=[echo_tool])
return result.text or ""
async with Client(mcp) as client:
await client.call_tool("driver", {})
spans = _spans_named(trace_exporter, "sampling tool echo_tool")
assert len(spans) == 1
span = spans[0]
assert span.attributes is not None
assert span.attributes["gen_ai.tool.name"] == "echo_tool"
assert span.attributes["fastmcp.tool.use_id"] == "call_1"
# The sampler never forwards attributes, so on_start legitimately sees
# none — this documents that limitation rather than asserting around it.
assert non_forwarding_recorder.attributes["sampling tool echo_tool"] == {}
class TestAttributeRestoreRespectsSampler:
"""Restoring missing attributes must not clobber attributes a sampler
deliberately kept and modified, must coexist with attributes a sampler
adds of its own, and must not fire at all when a sampler supplies only
its own attributes and drops FastMCP's entirely."""
@pytest.fixture
def redacting_tracer(
self, monkeypatch: pytest.MonkeyPatch, trace_exporter: InMemorySpanExporter
) -> None:
provider = TracerProvider(sampler=RedactingSampler())
provider.add_span_processor(SimpleSpanProcessor(trace_exporter))
tracer = provider.get_tracer("test")
monkeypatch.setattr("fastmcp.server.sampling.run.get_tracer", lambda: tracer)
@pytest.fixture
def attribute_adding_tracer(
self, monkeypatch: pytest.MonkeyPatch, trace_exporter: InMemorySpanExporter
) -> None:
provider = TracerProvider(sampler=AttributeAddingSampler())
provider.add_span_processor(SimpleSpanProcessor(trace_exporter))
tracer = provider.get_tracer("test")
monkeypatch.setattr("fastmcp.server.sampling.run.get_tracer", lambda: tracer)
@pytest.fixture
def filtering_tracer(
self, monkeypatch: pytest.MonkeyPatch, trace_exporter: InMemorySpanExporter
) -> None:
provider = TracerProvider(sampler=FilteringSampler())
provider.add_span_processor(SimpleSpanProcessor(trace_exporter))
tracer = provider.get_tracer("test")
monkeypatch.setattr("fastmcp.server.sampling.run.get_tracer", lambda: tracer)
async def test_create_message_span_keeps_redacted_value(
self,
trace_exporter: InMemorySpanExporter,
redacting_tracer: None,
):
def sampling_handler(
messages: list[SamplingMessage],
params: SamplingParams,
ctx: RequestContext,
) -> str:
return "sampled-text"
mcp = FastMCP("sampling-server")
@mcp.tool
async def ask(question: str, context: Context) -> str:
result = await context.sample(messages=question)
return result.text or ""
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")
assert len(spans) == 1
span = spans[0]
assert span.attributes is not None
# The redaction survives — it must not be overwritten by a restore.
assert span.attributes["mcp.method.name"] == "REDACTED"
# Attributes the sampler didn't touch are still present.
assert span.attributes["fastmcp.server.name"] == "sampling-server"
async def test_create_message_span_keeps_sampler_added_attribute(
self,
trace_exporter: InMemorySpanExporter,
attribute_adding_tracer: None,
):
def sampling_handler(
messages: list[SamplingMessage],
params: SamplingParams,
ctx: RequestContext,
) -> str:
return "sampled-text"
mcp = FastMCP("sampling-server")
@mcp.tool
async def ask(question: str, context: Context) -> str:
result = await context.sample(messages=question)
return result.text or ""
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")
assert len(spans) == 1
span = spans[0]
assert span.attributes is not None
assert span.attributes["sampling.policy"] == "always_on"
assert span.attributes["mcp.method.name"] == "sampling/createMessage"
assert span.attributes["fastmcp.server.name"] == "sampling-server"
async def test_tool_span_keeps_redacted_value(
self,
trace_exporter: InMemorySpanExporter,
redacting_tracer: None,
):
from mcp_types import CreateMessageResultWithTools, ToolUseContent
def echo_tool(text: str) -> str:
return text
call_count = 0
def sampling_handler(
messages: list[SamplingMessage],
params: SamplingParams,
ctx: RequestContext,
) -> CreateMessageResultWithTools:
nonlocal call_count
call_count += 1
if call_count == 1:
return CreateMessageResultWithTools(
role="assistant",
content=[
ToolUseContent(
type="tool_use",
id="call_1",
name="echo_tool",
input={"text": "hi"},
)
],
model="test-model",
stop_reason="toolUse",
)
return CreateMessageResultWithTools(
role="assistant",
content=[TextContent(type="text", text="done")],
model="test-model",
stop_reason="endTurn",
)
mcp = FastMCP(sampling_handler=sampling_handler)
@mcp.tool
async def driver(context: Context) -> str:
result = await context.sample(messages="go", tools=[echo_tool])
return result.text or ""
async with Client(mcp) as client:
await client.call_tool("driver", {})
spans = _spans_named(trace_exporter, "sampling tool echo_tool")
assert len(spans) == 1
span = spans[0]
assert span.attributes is not None
# RedactingSampler only touches mcp.method.name, which this span
# doesn't set — its attributes are forwarded unchanged, confirming
# the restore step doesn't disturb them either.
assert span.attributes["gen_ai.tool.name"] == "echo_tool"
assert span.attributes["fastmcp.tool.use_id"] == "call_1"
async def test_tool_span_keeps_sampler_added_attribute(
self,
trace_exporter: InMemorySpanExporter,
attribute_adding_tracer: None,
):
from mcp_types import CreateMessageResultWithTools, ToolUseContent
def echo_tool(text: str) -> str:
return text
call_count = 0
def sampling_handler(
messages: list[SamplingMessage],
params: SamplingParams,
ctx: RequestContext,
) -> CreateMessageResultWithTools:
nonlocal call_count
call_count += 1
if call_count == 1:
return CreateMessageResultWithTools(
role="assistant",
content=[
ToolUseContent(
type="tool_use",
id="call_1",
name="echo_tool",
input={"text": "hi"},
)
],
model="test-model",
stop_reason="toolUse",
)
return CreateMessageResultWithTools(
role="assistant",
content=[TextContent(type="text", text="done")],
model="test-model",
stop_reason="endTurn",
)
mcp = FastMCP(sampling_handler=sampling_handler)
@mcp.tool
async def driver(context: Context) -> str:
result = await context.sample(messages="go", tools=[echo_tool])
return result.text or ""
async with Client(mcp) as client:
await client.call_tool("driver", {})
spans = _spans_named(trace_exporter, "sampling tool echo_tool")
assert len(spans) == 1
span = spans[0]
assert span.attributes is not None
assert span.attributes["sampling.policy"] == "always_on"
assert span.attributes["gen_ai.tool.name"] == "echo_tool"
assert span.attributes["fastmcp.tool.use_id"] == "call_1"
async def test_create_message_span_filtered_attributes_are_not_restored(
self,
trace_exporter: InMemorySpanExporter,
filtering_tracer: None,
):
"""Regression: a sampler that intentionally supplies only its own
attributes must not have FastMCP's attributes restored on top."""
def sampling_handler(
messages: list[SamplingMessage],
params: SamplingParams,
ctx: RequestContext,
) -> str:
return "sampled-text"
mcp = FastMCP("sampling-server")
@mcp.tool
async def ask(question: str, context: Context) -> str:
result = await context.sample(messages=question)
return result.text or ""
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")
assert len(spans) == 1
span = spans[0]
assert dict(span.attributes or {}) == {"sampling.policy": "filtered"}
async def test_tool_span_filtered_attributes_are_not_restored(
self,
trace_exporter: InMemorySpanExporter,
filtering_tracer: None,
):
"""Regression: a sampler that intentionally supplies only its own
attributes must not have FastMCP's attributes restored on top."""
from mcp_types import CreateMessageResultWithTools, ToolUseContent
def echo_tool(text: str) -> str:
return text
call_count = 0
def sampling_handler(
messages: list[SamplingMessage],
params: SamplingParams,
ctx: RequestContext,
) -> CreateMessageResultWithTools:
nonlocal call_count
call_count += 1
if call_count == 1:
return CreateMessageResultWithTools(
role="assistant",
content=[
ToolUseContent(
type="tool_use",
id="call_1",
name="echo_tool",
input={"text": "hi"},
)
],
model="test-model",
stop_reason="toolUse",
)
return CreateMessageResultWithTools(
role="assistant",
content=[TextContent(type="text", text="done")],
model="test-model",
stop_reason="endTurn",
)
mcp = FastMCP(sampling_handler=sampling_handler)
@mcp.tool
async def driver(context: Context) -> str:
result = await context.sample(messages="go", tools=[echo_tool])
return result.text or ""
async with Client(mcp) as client:
await client.call_tool("driver", {})
spans = _spans_named(trace_exporter, "sampling tool echo_tool")
assert len(spans) == 1
span = spans[0]
assert dict(span.attributes or {}) == {"sampling.policy": "filtered"}
class TestRestoreDoesNotChurnAttributeLimitEvictions:
"""Regression: under a low `OTEL_SPAN_ATTRIBUTE_COUNT_LIMIT`, the restore
step in `fastmcp.server.sampling.run` must not reinsert an attribute the
SDK's bounded attribute map already evicted.
See `tests/telemetry/test_span_attributes.py::
test_restore_does_not_churn_sdk_attribute_limit_evictions` for the full
explanation: an evicted key looks identical to a sampler-omitted one from
inside the restore helper, so reinserting it churns which attributes the
SDK ultimately retains and inflates `dropped_attributes` beyond what the
limit alone already cost. This proves the same discriminator holds
end-to-end through the real sampling call path, not just at the helper
level.
"""
@staticmethod
async def _run_create_message(
span_limits: SpanLimits, *, stub_restore: bool
) -> ReadableSpan:
with pytest.MonkeyPatch.context() as mp:
exporter = InMemorySpanExporter()
provider = TracerProvider(span_limits=span_limits)
provider.add_span_processor(SimpleSpanProcessor(exporter))
tracer = provider.get_tracer("test")
mp.setattr("fastmcp.server.sampling.run.get_tracer", lambda: tracer)
if stub_restore:
mp.setattr(
"fastmcp.server.sampling.run.restore_dropped_attributes",
lambda span, attrs: None,
)
def sampling_handler(
messages: list[SamplingMessage],
params: SamplingParams,
ctx: RequestContext,
) -> str:
return "sampled-text"
mcp = FastMCP("sampling-server")
@mcp.tool
async def ask(question: str, context: Context) -> str:
result = await context.sample(messages=question)
return result.text or ""
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")
assert len(spans) == 1
return spans[0]
@staticmethod
async def _run_tool_span(
span_limits: SpanLimits, *, stub_restore: bool
) -> ReadableSpan:
from mcp_types import CreateMessageResultWithTools, ToolUseContent
with pytest.MonkeyPatch.context() as mp:
exporter = InMemorySpanExporter()
provider = TracerProvider(span_limits=span_limits)
provider.add_span_processor(SimpleSpanProcessor(exporter))
tracer = provider.get_tracer("test")
mp.setattr("fastmcp.server.sampling.run.get_tracer", lambda: tracer)
if stub_restore:
mp.setattr(
"fastmcp.server.sampling.run.restore_dropped_attributes",
lambda span, attrs: None,
)
def echo_tool(text: str) -> str:
return text
call_count = 0
def sampling_handler(
messages: list[SamplingMessage],
params: SamplingParams,
ctx: RequestContext,
) -> CreateMessageResultWithTools:
nonlocal call_count
call_count += 1
if call_count == 1:
return CreateMessageResultWithTools(
role="assistant",
content=[
ToolUseContent(
type="tool_use",
id="call_1",
name="echo_tool",
input={"text": "hi"},
)
],
model="test-model",
stop_reason="toolUse",
)
return CreateMessageResultWithTools(
role="assistant",
content=[TextContent(type="text", text="done")],
model="test-model",
stop_reason="endTurn",
)
mcp = FastMCP(sampling_handler=sampling_handler)
@mcp.tool
async def driver(context: Context) -> str:
result = await context.sample(messages="go", tools=[echo_tool])
return result.text or ""
async with Client(mcp) as client:
await client.call_tool("driver", {})
spans = _spans_named(exporter, "sampling tool echo_tool")
assert len(spans) == 1
return spans[0]
async def test_create_message_span(self, monkeypatch: pytest.MonkeyPatch):
# Two attributes on this span (`mcp.method.name`, `fastmcp.server.name`)
# — a limit of 1 guarantees eviction.
monkeypatch.setenv("OTEL_SPAN_ATTRIBUTE_COUNT_LIMIT", "1")
span_limits = SpanLimits()
assert span_limits.max_span_attributes == 1
baseline = await self._run_create_message(span_limits, stub_restore=True)
# The whole test is moot if the limit didn't actually bind.
assert baseline.dropped_attributes > 0
with_restore = await self._run_create_message(span_limits, stub_restore=False)
assert dict(with_restore.attributes or {}) == dict(baseline.attributes or {})
assert with_restore.dropped_attributes == baseline.dropped_attributes
async def test_tool_span(self, monkeypatch: pytest.MonkeyPatch):
# Two attributes on this span (`gen_ai.tool.name`, `fastmcp.tool.use_id`)
# — a limit of 1 guarantees eviction.
monkeypatch.setenv("OTEL_SPAN_ATTRIBUTE_COUNT_LIMIT", "1")
span_limits = SpanLimits()
assert span_limits.max_span_attributes == 1
baseline = await self._run_tool_span(span_limits, stub_restore=True)
# The whole test is moot if the limit didn't actually bind.
assert baseline.dropped_attributes > 0
with_restore = await self._run_tool_span(span_limits, stub_restore=False)
assert dict(with_restore.attributes or {}) == dict(baseline.attributes or {})
assert with_restore.dropped_attributes == baseline.dropped_attributes

View file

@ -1,14 +1,12 @@
from unittest.mock import MagicMock
import pytest
from mcp_types import ModelPreferences
from fastmcp.server.context import (
Context,
reset_transport,
set_transport,
)
from fastmcp.server.sampling.run import _parse_model_preferences
from fastmcp.server.server import FastMCP
@ -17,28 +15,6 @@ def context():
return Context(fastmcp=FastMCP())
class TestParseModelPreferences:
def test_parse_model_preferences_string(self, context):
mp = _parse_model_preferences("claude-haiku-4-5")
assert isinstance(mp, ModelPreferences)
assert mp.hints is not None
assert mp.hints[0].name == "claude-haiku-4-5"
def test_parse_model_preferences_list(self, context):
mp = _parse_model_preferences(["claude-haiku-4-5", "claude"])
assert isinstance(mp, ModelPreferences)
assert mp.hints is not None
assert [h.name for h in mp.hints] == ["claude-haiku-4-5", "claude"]
def test_parse_model_preferences_object(self, context):
obj = ModelPreferences(hints=[])
assert _parse_model_preferences(obj) is obj
def test_parse_model_preferences_invalid_type(self, context):
with pytest.raises(ValueError):
_parse_model_preferences(model_preferences=123) # pyright: ignore[reportArgumentType] # type: ignore[invalid-argument-type] # ty:ignore[invalid-argument-type]
class TestSessionId:
def test_session_id_with_http_headers(self, context):
"""Test that session_id returns the value from mcp-session-id header."""

View file

@ -30,7 +30,7 @@ from mcp.shared.exceptions import MCPError
from pydantic import FileUrl
from fastmcp import Client as FastMCPClient
from fastmcp import Context, FastMCP, settings
from fastmcp import Context, FastMCP
from fastmcp.exceptions import PromptError, ResourceError
from fastmcp.server.elicitation import AcceptedElicitation
from fastmcp.server.middleware import Middleware
@ -215,16 +215,6 @@ def push_server() -> FastMCP:
assert isinstance(result, AcceptedElicitation)
return f"elicited {result.data}"
@mcp.tool
async def do_sample(ctx: Context) -> str:
result = await ctx.sample("hello")
return f"sampled {result.text}"
@mcp.tool
async def do_list_roots(ctx: Context) -> str:
roots = await ctx.list_roots()
return f"roots {[str(r.uri) for r in roots]}"
@mcp.tool
async def do_log(ctx: Context) -> str:
await ctx.info("a log line")
@ -264,31 +254,12 @@ async def test_elicit_works_on_legacy(push_server):
assert _texts(result.content) == ["elicited 7"]
async def test_sample_works_on_legacy(push_server):
async with SDKClient(
_server(push_server), mode="legacy", sampling_callback=_sampling_cb
) as client:
result = await client.call_tool("do_sample", {})
assert result.is_error is False
assert _texts(result.content) == ["sampled sampled-text"]
async def test_list_roots_works_on_legacy(push_server):
async with SDKClient(
_server(push_server), mode="legacy", list_roots_callback=_roots_cb
) as client:
result = await client.call_tool("do_list_roots", {})
assert result.is_error is False
assert _texts(result.content) == ["roots ['file:///tmp']"]
@pytest.mark.parametrize("mode", MODERN_MODES)
@pytest.mark.parametrize("tool", ["do_elicit", "do_sample", "do_list_roots"])
async def test_push_features_degrade_on_modern(push_server, mode, tool):
"""Server-initiated requests (elicitation/sampling/roots) are removed at
2026-07-28 (SEP-2577), so a tool that uses them must degrade to a surfaced
error rather than hang or crash the connection. This asserts the
degradation happens and reaches the caller as an isError result.
async def test_elicit_degrades_on_modern(push_server, mode):
"""Elicitation is a server-initiated request, removed at 2026-07-28
(SEP-2577), so a tool that uses it must degrade to a surfaced error rather
than hang or crash the connection. The connection survives: a subsequent
normal call still works.
"""
async with SDKClient(
_server(push_server),
@ -297,230 +268,46 @@ async def test_push_features_degrade_on_modern(push_server, mode, tool):
sampling_callback=_sampling_cb,
list_roots_callback=_roots_cb,
) as client:
result = await client.call_tool(tool, {})
result = await client.call_tool("do_elicit", {})
assert result.is_error is True
# A subsequent normal call still works: the connection survived the
# per-request failure rather than tearing down the whole session.
log_result = await client.call_tool("do_log", {})
assert log_result.is_error is False
async def test_list_roots_degradation_message_is_clear_on_modern(push_server):
"""`ctx.list_roots()` sends with no related_request_id, so the SDK selects
the connection's no-back-channel outbound and raises the self-explanatory
NoBackChannelError. This is the *good* degradation message and we assert it.
"""
async with SDKClient(_server(push_server), mode="2026-07-28") as client:
result = await client.call_tool("do_list_roots", {})
assert result.is_error is True
message = " ".join(_texts(result.content)).lower()
assert "back-channel" in message and "server-initiated" in message
@pytest.mark.parametrize("tool", ["do_elicit", "do_sample"])
async def test_elicit_sample_degradation_message_is_clear_on_modern(push_server, tool):
"""FastMCP era-gates elicit/sample: on a 2026-07-28 connection they raise a
clear, era-aware error before hitting the wire, instead of the SDK's opaque
'Method not found' (sdk-feedback.md #10). Both messages name the removed
server-initiated capability so the caller knows why the request degraded.
async def test_elicit_degradation_message_is_clear_on_modern(push_server):
"""FastMCP era-gates elicit: on a 2026-07-28 connection it raises a clear,
era-aware error before hitting the wire, instead of the SDK's opaque
'Method not found' (sdk-feedback.md #10).
"""
async with SDKClient(
_server(push_server),
mode="2026-07-28",
elicitation_callback=_accept_elicit,
sampling_callback=_sampling_cb,
) as client:
result = await client.call_tool(tool, {})
assert result.is_error is True
message = " ".join(_texts(result.content)).lower()
assert "server-initiated" in message
# ---------------------------------------------------------------------------
# 3a-bis. Server-configured sampling handler answers WITHOUT the client
# back-channel, so ctx.sample()/ctx.sample_step() must keep working on modern
# connections. The era-gate only fires when nothing can serve the request.
# ---------------------------------------------------------------------------
def _handler_server(behavior) -> FastMCP:
"""A server whose sampling is answered by a server-side handler."""
def sampling_handler(messages, params, ctx) -> str:
return "handler-answer"
mcp = FastMCP("handler", sampling_handler=sampling_handler)
if behavior is not None:
mcp.sampling_handler_behavior = behavior
@mcp.tool
async def do_sample(ctx: Context) -> str:
result = await ctx.sample("hello")
return f"sampled {result.text}"
@mcp.tool
async def do_sample_step(ctx: Context) -> str:
step = await ctx.sample_step("hello")
return f"stepped {step.text}"
return mcp
@pytest.mark.parametrize("mode", MODERN_MODES)
@pytest.mark.parametrize("behavior", ["always", "fallback"])
@pytest.mark.parametrize("method", ["do_sample", "do_sample_step"])
async def test_server_sampling_handler_works_on_modern(mode, behavior, method):
"""A server-side sampling handler answers entirely server-side, so it works
on modern (2026-07-28) connections regardless of behavior. The era-gate must
NOT block these nothing touches the removed client back-channel. Crucially,
'fallback' must go straight to the handler (no bare client-attempt failure)."""
server = _handler_server(behavior)
async with SDKClient(_server(server), mode=mode) as client:
result = await client.call_tool(method, {})
assert result.is_error is False
assert "handler-answer" in " ".join(_texts(result.content))
@pytest.mark.parametrize("behavior", ["always", "fallback"])
@pytest.mark.parametrize("method", ["do_sample", "do_sample_step"])
async def test_server_sampling_handler_works_on_legacy(behavior, method):
"""Handshake-era behavior is unchanged: the server-side handler still answers
on legacy connections."""
server = _handler_server(behavior)
async with SDKClient(_server(server), mode="legacy") as client:
result = await client.call_tool(method, {})
assert result.is_error is False
assert "handler-answer" in " ".join(_texts(result.content))
@pytest.mark.parametrize("mode", MODERN_MODES)
@pytest.mark.parametrize("method", ["do_sample", "do_sample_step"])
async def test_sampling_without_handler_still_era_gated_on_modern(
push_server, mode, method
):
"""With no server-side handler configured, the request would hit the removed
client back-channel, so the clear era error still fires on modern."""
# push_server only defines do_sample; add a do_sample_step twin inline.
mcp = FastMCP("no-handler")
@mcp.tool
async def do_sample(ctx: Context) -> str:
result = await ctx.sample("hello")
return f"sampled {result.text}"
@mcp.tool
async def do_sample_step(ctx: Context) -> str:
step = await ctx.sample_step("hello")
return f"stepped {step.text}"
async with SDKClient(
_server(mcp), mode=mode, sampling_callback=_sampling_cb
) as client:
result = await client.call_tool(method, {})
result = await client.call_tool("do_elicit", {})
assert result.is_error is True
assert "server-initiated" in " ".join(_texts(result.content)).lower()
# ---------------------------------------------------------------------------
# 3b. Sampling deprecation warning (SEP-2577): ctx.sample/ctx.sample_step warn
# 3a-bis. Sampling and roots are not in the server API at all
# ---------------------------------------------------------------------------
@pytest.fixture
def reset_sample_warn_flag():
"""Reset the process-wide warn-once flag so a warning can be observed."""
import fastmcp.server.context as context_module
original = set(context_module._sample_deprecation_warned)
context_module._sample_deprecation_warned.clear()
try:
yield
finally:
context_module._sample_deprecation_warned.clear()
context_module._sample_deprecation_warned.update(original)
@pytest.mark.parametrize("name", ["sample", "sample_step", "list_roots"])
def test_removed_server_initiated_methods_are_absent(name):
"""FastMCP 4 targets the modern protocol, so the capabilities SEP-2577
removed are not in the server-authoring API not deprecated, not era-gated,
absent. A server that calls them fails at attribute lookup, in every era.
"""
assert not hasattr(Context, name)
@pytest.mark.parametrize("method", ["do_sample", "do_sample_step"])
async def test_sampling_emits_deprecation_warning(reset_sample_warn_flag, method):
"""`ctx.sample()` and `ctx.sample_step()` emit a FastMCPDeprecationWarning
naming SEP-2577 and the server-side-LLM migration path."""
from fastmcp.exceptions import FastMCPDeprecationWarning
mcp = FastMCP("warn")
@mcp.tool
async def do_sample(ctx: Context) -> str:
await ctx.sample("hello")
return "ok"
@mcp.tool
async def do_sample_step(ctx: Context) -> str:
await ctx.sample_step("hello")
return "ok"
with pytest.warns(FastMCPDeprecationWarning, match="SEP-2577"):
async with SDKClient(
_server(mcp), mode="legacy", sampling_callback=_sampling_cb
) as client:
await client.call_tool(method, {})
async def test_sampling_deprecation_warning_fires_once_per_process(
reset_sample_warn_flag,
):
"""The deprecation warning is warn-once: a second sample call in the same
process does not re-warn."""
from fastmcp.exceptions import FastMCPDeprecationWarning
mcp = FastMCP("warn-once")
@mcp.tool
async def do_sample(ctx: Context) -> str:
await ctx.sample("hello")
return "ok"
with pytest.warns(FastMCPDeprecationWarning):
async with SDKClient(
_server(mcp), mode="legacy", sampling_callback=_sampling_cb
) as client:
await client.call_tool("do_sample", {})
import warnings as _warnings
with _warnings.catch_warnings():
_warnings.simplefilter("error", FastMCPDeprecationWarning)
async with SDKClient(
_server(mcp), mode="legacy", sampling_callback=_sampling_cb
) as client:
result = await client.call_tool("do_sample", {})
assert result.is_error is False
async def test_sampling_deprecation_warning_suppressible_via_settings(
reset_sample_warn_flag, monkeypatch
):
"""Setting `deprecation_warnings=False` suppresses the sampling warning,
matching the house pattern for every other FastMCP deprecation."""
import warnings as _warnings
from fastmcp.exceptions import FastMCPDeprecationWarning
monkeypatch.setattr(settings, "deprecation_warnings", False)
mcp = FastMCP("no-warn")
@mcp.tool
async def do_sample(ctx: Context) -> str:
await ctx.sample("hello")
return "ok"
with _warnings.catch_warnings():
_warnings.simplefilter("error", FastMCPDeprecationWarning)
async with SDKClient(
_server(mcp), mode="legacy", sampling_callback=_sampling_cb
) as client:
result = await client.call_tool("do_sample", {})
assert result.is_error is False
@pytest.mark.parametrize("kwarg", ["sampling_handler", "sampling_handler_behavior"])
def test_server_sampling_handler_kwargs_are_rejected(kwarg):
"""The server-side sampling handler existed only to answer `ctx.sample()`."""
with pytest.raises(TypeError, match="SEP-2577"):
FastMCP("gone", **{kwarg: None}) # ty: ignore[invalid-argument-type]
@pytest.mark.parametrize("mode", MODERN_MODES)
@ -566,23 +353,17 @@ async def test_session_id_access_does_not_crash_on_modern(sessionless_server, mo
@pytest.mark.parametrize("mode", MODERN_MODES)
async def test_set_logging_level_does_not_crash_on_modern(sessionless_server, mode):
"""logging/setLevel is a session-id-keyed, deprecated-at-2026 operation.
On a sessionless modern in-memory connection it must degrade cleanly (either
succeed as a no-op or raise a surfaced MCPError) rather than crash the
connection. Characterization: capture whichever the current contract is.
async def test_set_logging_level_is_era_gated_on_modern(sessionless_server, mode):
"""`logging/setLevel` asks a server to remember a level for the session, and
the modern era has no session the method is absent from its registry. The
FastMCP client says so plainly instead of no-opping or surfacing the SDK's
opaque "Method not found", and the connection stays usable afterward.
"""
async with SDKClient(_server(sessionless_server), mode=mode) as client:
outcome: str
try:
await client.set_logging_level("debug") # ty: ignore[deprecated]
outcome = "ok"
except MCPError:
outcome = "mcperror"
# Either way the connection is still usable afterward.
async with FastMCPClient(sessionless_server, mode=mode) as client:
with pytest.raises(RuntimeError, match="2026-07-28"):
await client.set_logging_level("debug")
result = await client.call_tool("read_session_id", {})
assert result.is_error is False
assert outcome in {"ok", "mcperror"}
# ---------------------------------------------------------------------------