mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-09 15:19:10 +02:00
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:
commit
b1e0586d4e
46 changed files with 417 additions and 5960 deletions
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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. |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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]'`.
|
||||
|
|
@ -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())
|
||||
|
|
@ -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())
|
||||
|
|
@ -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())
|
||||
|
|
@ -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())
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
```
|
||||
"""
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
```
|
||||
"""
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]
|
||||
|
|
@ -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})")
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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})"
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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]:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
@ -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"]
|
||||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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)).
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue