mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-09 15:19:10 +02:00
* Add guard-mode MRTR server support (SEP-2322) * Add server-side MRTR guard tests * Add MRTR guard docs, exports, and output-schema handling * Apply formatting to MRTR guard changes * Fix MRTR review round 1: middleware-safe suspend, Annotated strip, stable audience - ToolInputRequired subclasses BaseException (CancelledError precedent) so error middleware's broad except Exception cannot swallow a suspension - Strip InputRequiredResult arms inside Annotated return types - Reject a custom RequestStateSecurity without a stable audience (random per-replica server names would break shared-key verification) * Fix static analysis: rewrite tuple([...]) as tuple literal (C409) * Recognize InputRequiredResult inside Annotated union arms _is_input_required_type now peels Annotated first, so a metadata-carrying guard arm (str | Annotated[InputRequiredResult, Field(...)]) is stripped and the data arm's output schema survives. * docs: frame multi-round tools as elicitation on the modern protocol Fold multi-round-tools.mdx into elicitation.mdx as two eras of one capability; drop pause/suspend framing for the stateless per-round model. * Transport MRTR asks as InputRequiredToolResult, not a raised signal An input-required result is the full result of a stateless MRTR leg, so it flows through the middleware chain as an ordinary ToolResult subclass instead of a raised ToolInputRequired(BaseException). Middleware observes it, caching skips it, and response-limiting leaves it untouched. * Document MRTR middleware interaction and the isinstance pattern * Update MRTR change-register verify note to InputRequiredToolResult * Align test module docstring with result-cycle framing * Fix MRTR review: bypass cache on continuation legs; soften audience guard - ResponseCachingMiddleware skips read AND write on continuation legs: the cache key is name+arguments only, so a continuation's final result would be served to later fresh calls, which would never be asked - The stable-audience check is a warning, not an error: a policy object cannot reveal whether its keys are shared, and single-process customization (ephemeral ttl, custom codec) is legitimate unnamed * Treat state-only rounds as continuations in the response cache A round carrying request_state but no questions retries with input_responses=None; request_state alone must bypass the cache or its terminal result is stored under the fresh-call key. * Fix MRTR review round: preserve asks through transforms, empty-name audience, docs predicate - TransformedTool.run returns an InputRequiredToolResult intact instead of reshaping it into an empty ToolResult for non-object output schemas - audience warning uses a falsy-name check (empty string also autogenerates a per-replica name) - the elicitation docs continuation predicate checks request_state too * Add create_proxy(mode=) opt-in for guard round-tripping through proxies An auto-created proxy client stays handshake-era by default (a dual-era backend serves both, and one proxy session is one era; handshake preserves server-initiated push forwarding). Pass create_proxy(target, mode="auto") to negotiate modern so an upstream guard's InputRequiredResult round-trips — the two are mutually exclusive per session. * Wrap raw InputRequiredResult returned by a transform_fn A custom transform function may return the raw ask directly, like any tool body — wrap it into InputRequiredToolResult so it survives output normalization and reaches the wire, not only pre-wrapped forwarded guards. * Reject input-required results from background tasks * Unwrap type aliases before stripping guard arms * Apply ruff format * Recursively strip guard arms through nested and composed aliases * Reflect MRTR continuation fields on the middleware message * Suppress output schema for InputRequiredResult subclasses * Forward progress on modern proxy tool calls * Suppress output schema for bare aliased guard returns * Suppress output schema for any surviving guard return wrapping
131 lines
8.9 KiB
Text
131 lines
8.9 KiB
Text
---
|
|
title: Feature Program
|
|
---
|
|
|
|
The migration is the foundation. The forward v4 program is a sequence of post-merge PRs that build on it. Each feature below carries an explicit status:
|
|
|
|
- **Designed** — the approach is settled and an API sketch exists; implementation has not started.
|
|
- **Planned** — the shape is agreed but design details remain open.
|
|
- **Not started** — identified as v4 scope, not yet designed.
|
|
|
|
Code blocks marked as sketches show the *intended* API and do not resolve against the current tree.
|
|
|
|
## Sampling: deprecate now, remove in 4.0
|
|
|
|
**Status: Designed.**
|
|
|
|
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 already 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).
|
|
|
|
The plan is Option A: **deprecate the push-sampling API now and remove it in the 4.0 release.**
|
|
|
|
- Deprecate `ctx.sample` / `ctx.sample_step` and the server sampling module now.
|
|
- Era-gate them to raise a clear error on `2026-07-28` (this also fixes the opaque "Method not found" of sdk-feedback #10).
|
|
- Remove `ctx.sample`, `ctx.sample_step`, `server/sampling/`, `SamplingTool`, and structured-result sampling in 4.0.
|
|
|
|
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.
|
|
|
|
In this PR, sampling still functions on the legacy eras. Users already see an SDK-level `MCPDeprecationWarning` on ordinary `ctx.sample` usage (the SDK deprecated the capability wire-side per SEP-2577, verified empirically by WS2), but FastMCP's own deprecation — warnings with migration guidance, plus the era-gating — lands as the first follow-up PR.
|
|
|
|
## MRTR elicitation
|
|
|
|
**Status: Guard form shipped (4.0). Declarative `Resolve` layer designed.**
|
|
|
|
Elicitation survives the modern era through multi-round-trip (MRTR). The 2026 wire envelope carries elicitation as a multi-round input-request: a tool returns an `InputRequiredResult` and re-runs per round, each round a complete request→response cycle. Imperative `ctx.elicit` relies on the session back-channel, which is gone on `2026-07-28` foreground calls; on the modern era, elicitation is reachable through MRTR instead.
|
|
|
|
The **guard form** of this is shipped in 4.0 (see [Elicitation on the modern protocol](/servers/elicitation#elicitation-on-the-modern-protocol)): a tool returns an `InputRequiredResult` and reads the client's answers off `ctx.input_responses` / `ctx.request_state`, re-running each round. It mirrors the SDK's base guard model exactly — no FastMCP-invented DX, the framework owns `request_state` sealing, and returning this result on a handshake-era connection produces a clear era error. The declarative `Resolve(...)` layer below sits *on top of* this primitive and remains designed but not yet built.
|
|
|
|
The design does both, so the imperative DX survives where it can and a declarative surface covers the modern era:
|
|
|
|
**1. Keep `ctx.elicit` as the primary imperative DX,** re-plumbed to be era-aware: legacy connections use the session elicit-form path; background tasks on any era use the existing Redis relay (the task's `input_required` status *is* the MRTR suspension boundary); foreground calls on `2026-07-28` raise a clear era-aware error pointing at the declarative form.
|
|
|
|
**2. Add a declarative surface** in a new `fastmcp.elicitation` module — `Resolve`, `Elicit`, and `ElicitationResult` — thin wrappers over the SDK's resolver, wired into FastMCP's own tool layer (FastMCP tools do not inherit the SDK's auto-resolver wiring).
|
|
|
|
The intended DX (sketch — the module does not exist yet):
|
|
|
|
```python test="skip"
|
|
from typing import Annotated
|
|
|
|
from pydantic import BaseModel
|
|
|
|
from fastmcp import FastMCP, Context
|
|
from fastmcp.elicitation import Resolve, Elicit, ElicitationResult
|
|
|
|
mcp = FastMCP("shipping")
|
|
|
|
|
|
class Address(BaseModel):
|
|
street: str
|
|
city: str
|
|
zip: str
|
|
|
|
|
|
async def ask_address(ctx: Context) -> Elicit[Address]:
|
|
return Elicit("Where should we ship this order?", Address)
|
|
|
|
|
|
@mcp.tool
|
|
async def create_shipment(
|
|
order_id: str,
|
|
address: Annotated[Address, Resolve(ask_address)], # unwrapped; decline -> ToolError
|
|
) -> str:
|
|
return f"Shipping {order_id} to {address.city}"
|
|
|
|
|
|
@mcp.tool
|
|
async def maybe_ship(
|
|
order_id: str,
|
|
address: Annotated[ElicitationResult[Address], Resolve(ask_address)], # full outcome
|
|
) -> str:
|
|
if address.action != "accept":
|
|
return "cancelled"
|
|
return f"Shipping {order_id} to {address.data.city}"
|
|
|
|
|
|
@mcp.tool(task=True)
|
|
async def slow_ship(ctx: Context) -> str:
|
|
# imperative ctx.elicit survives 2026 via the background-task relay
|
|
result = await ctx.elicit("Confirm address", Address)
|
|
if result.action == "accept":
|
|
return f"Shipping to {result.data.city}"
|
|
return "cancelled"
|
|
```
|
|
|
|
The registration path detects `Annotated[_, Resolve(...)]` parameters, builds resolver plans, and returns the SDK's `InputRequiredResult` instead of the tool body on the first round. The FastMCP client already dispatches input-requests through its elicitation callback; the follow-up work confirms the FastMCP client wrapper drives the input-required driver the way the SDK's own client does.
|
|
|
|
The divergence between elicitation and sampling on 2026 comes down to one fact: the SDK built the server-side emitter for elicitation (`Elicit`/`Resolve`) and not for sampling. The wire carries all three input-request types and the client dispatches all three; only elicitation can produce one server-side. That is why elicitation survives 4.0 via MRTR and push-sampling does not.
|
|
|
|
## Middleware on the SDK `ServerMiddleware` seam
|
|
|
|
**Status: Planned.**
|
|
|
|
The migration already routes `initialize` interception through the SDK's new `ServerMiddleware` seam via `FastMCPServerMiddleware`. The forward work is to lean into that seam more fully — moving more of FastMCP's request-lifecycle middleware onto the native SDK composition point rather than FastMCP-side wrappers, now that the SDK composes middleware around every request and notification.
|
|
|
|
## First-class 2026 client
|
|
|
|
**Status: Planned.**
|
|
|
|
The migration keeps `fastmcp.Client` as a wrapper around `mcp.ClientSession` in legacy/handshake mode. The v4 client work adopts the SDK's first-class `mcp.client.Client`: a `mode='auto'` that negotiates the era, `discover()` for sessionless capability discovery, and the MRTR input-required driver so the client can answer multi-round elicitation and sampling input-requests. This is the client-side half of full `2026-07-28` support.
|
|
|
|
This workstream also owns the server-side statelessness design holes — `ctx.session_id` / `set_state` round-tripping, task push and background elicitation, and stateful-proxy affinity — since all three turn on the same "what is a session without a session?" question. See [Statelessness on 2026-07-28](/development/v4-notes/known-gaps#statelessness-on-2026-07-28) for the full accounting.
|
|
|
|
## Subscriptions, cache hints, extensions, OTel
|
|
|
|
**Status: Not started.**
|
|
|
|
A cluster of protocol features tracked for v4 once the core client and elicitation work lands: a `subscriptions/listen` surface backed by a subscription bus, resource cache hints, reconciliation of the `extensions` / MCP Apps capability advertisement across eras (the `extensions` capability is stripped at pre-2026 negotiated versions today — sdk-feedback #2), and the OpenTelemetry integration re-checked against the SDK's own OTel middleware.
|
|
|
|
## SDK delegation, round two
|
|
|
|
**Status: Planned (gated on upstream).**
|
|
|
|
The real HTTP simplification is a v4 project, not this PR. FastMCP can collapse its `create_streamable_http_app` onto the SDK's `Server.streamable_http_app()` once upstream adds three things:
|
|
|
|
1. per-session event-store scoping,
|
|
2. a user-middleware injection hook,
|
|
3. a lifespan hook.
|
|
|
|
The payoff is not only less code — FastMCP would also inherit the SDK's session-owner credential enforcement, a security gain it lacks today. These are the three upstream feature requests to file (alongside the advisory dossier described in [Known Gaps](/development/v4-notes/known-gaps)). Until they land, the four HTTP overrides in the [Change Register](/development/v4-notes/change-register#http) stay.
|
|
|
|
One latent capability worth surfacing on FastMCP's side: `session_idle_timeout` is accepted by the manager but never set by `create_streamable_http_app` — a one-line plumb if FastMCP wants to expose it.
|