From 16cd68d520fa30fbdba1ee653f42222c063017a7 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Tue, 28 Jul 2026 17:18:25 -0400 Subject: [PATCH] Reflect declared parameters across the docs Migration guides now map the SDK's Resolve to Elicit rather than claiming FastMCP has no resolver injection; the FastMCP 3 guide leads its ctx.elicit era-gate section with declaring the parameter, since that is the fix that serves both eras. Client docs cover answering the rounds yourself, including the two same-named ElicitResult types. Also fixes a pre-existing broken anchor in the FastMCP 3 guide. --- docs/clients/elicitation.mdx | 39 ++++++++++++++++ .../upgrading/from-fastmcp-3.mdx | 42 ++++++++++++++--- .../upgrading/from-mcp-sdk-v2.mdx | 45 ++++++++++++------- docs/getting-started/whats-new.mdx | 2 +- docs/more/faq.mdx | 6 ++- docs/servers/context.mdx | 13 ++++++ 6 files changed, 123 insertions(+), 24 deletions(-) diff --git a/docs/clients/elicitation.mdx b/docs/clients/elicitation.mdx index 73cc6fdb7..302c908e4 100644 --- a/docs/clients/elicitation.mdx +++ b/docs/clients/elicitation.mdx @@ -164,3 +164,42 @@ client = Client( input_required_max_rounds=5, ) ``` + +### Answering the rounds yourself + +A handler answers a question the moment it is asked, which only works when the answer is available right then. Often it is not — a web app shows a form and the reply arrives on a different request, minutes later, possibly in another process. That is what the sessionless protocol is built for, and a callback cannot span it. + +So a client with **no** `elicitation_handler` is handed the request instead of being asked to answer it. `CallToolResult.input_required` carries what the server asked, and you answer by calling again with `input_responses` and the `request_state` it came with: + +```python +from fastmcp import Client +from mcp_types import ElicitResult + +async with Client("https://example.com/mcp") as client: + result = await client.call_tool("book_flight") + + while result.input_required: + answers = { + key: ElicitResult(action="accept", content={"value": show_form(request)}) + for key, request in result.input_required.input_requests.items() + } + result = await client.call_tool( + "book_flight", + input_responses=answers, + request_state=result.input_required.request_state, + ) + + print(result.data) +``` + +Every question the server grouped into one round arrives together, so a form can render them as one screen — which is what a server that batched its questions intended. A handler receives them one at a time and loses that grouping. + +`request_state` is opaque and sealed by the server; pass it back exactly as received. Nothing is stored between rounds, so the next call can be served by a different worker entirely. + + +Answering by hand uses `mcp_types.ElicitResult`, whose `content` is a plain dict matching the requested schema. That is a different class from the `fastmcp.client.elicitation.ElicitResult` an `elicitation_handler` returns, where `content` is an instance of the `response_type` FastMCP built for you. The names are the same and the `content` types are not, so import the one that matches the path you are on. + + + +This applies to `2026-07-28` connections, where the request is a result you can inspect. On handshake-era connections the server is blocked mid-call waiting on the back-channel, so there is nothing to hand back — a client without an `elicitation_handler` gets an "Elicitation not supported" error, which is accurate there. + diff --git a/docs/getting-started/upgrading/from-fastmcp-3.mdx b/docs/getting-started/upgrading/from-fastmcp-3.mdx index 859c1d911..b159fe2b5 100644 --- a/docs/getting-started/upgrading/from-fastmcp-3.mdx +++ b/docs/getting-started/upgrading/from-fastmcp-3.mdx @@ -72,7 +72,7 @@ REMOVED CONTEXT METHODS - The client side is NOT affected — `Client(sampling_handler=...)` and `Client(roots=...)` still mean what they meant. RUNTIME BREAKS THAT STILL COMPILE — the ones most likely to reach production -- `ctx.elicit(...)` anywhere. It is era-gated in 4.0 and raises on modern connections, which is what `Client` now negotiates by default. This is the single most likely runtime failure. +- `ctx.elicit(...)` anywhere. It is era-gated in 4.0 and raises on modern connections, which is what `Client` now negotiates by default. This is the single most likely runtime failure. The fix that serves both eras is to declare the value as a parameter with `Elicit`. - `ctx.elicit(...)` called without `response_type` - `except httpx.` around any FastMCP call. FastMCP raises httpx2 exceptions now, but httpx is usually still installed transitively, so the handler imports, type-checks, and silently never matches. - a custom `httpx.AsyncClient`, `httpx_client_factory=`, or `httpx.Auth` handed to a FastMCP transport, `OAuth`, or `from_openapi` @@ -138,7 +138,7 @@ Every protocol type — `TextContent`, `ImageContent`, `Tool`, `ErrorData`, `Ico from mcp.types import TextContent, Tool, ToolAnnotations ``` -Both names resolve to the same objects, so `from mcp_types import X` is equally valid — useful if you depend on the types without the rest of the SDK. What did change is the fields on those types: they are snake_case now (`input_schema`, not `inputSchema`), which the [compatibility bridge](#legacy-camelcase-field-access-keeps-working) covers for the objects FastMCP hands you. +Both names resolve to the same objects, so `from mcp_types import X` is equally valid — useful if you depend on the types without the rest of the SDK. What did change is the fields on those types: they are snake_case now (`input_schema`, not `inputSchema`), which the [compatibility bridge](#camelcase-field-access) covers for the objects FastMCP hands you. `fastmcp.types` still exists, but holds only types FastMCP defines itself (currently just `Textarea`, used to render a multiline textarea in form-based UIs) — it does not re-export protocol types. @@ -381,7 +381,37 @@ These changes compile fine and can surface at runtime. The first is the one most ToolError: elicitation via server-initiated requests is unavailable on 2026-07-28 connections. ``` -The gate is strict in both directions, which is what makes it debuggable: a guard tool that returns an input request on a handshake connection raises the mirror-image error rather than misbehaving quietly. You have three ways forward. Rewrite the tool as a guard tool that *returns* a description of the input it needs, which is the form that works on modern connections. Branch on `ctx.request_context.protocol_version` and keep both paths if you serve both eras. Or keep this server's clients on the handshake era with `Client(server, mode="legacy")`, which leaves `ctx.elicit()` working as written. See [Elicitation](/servers/elicitation#which-approach-to-use) for the two shapes side by side. +The gate is strict in both directions, which is what makes it debuggable: a guard tool that returns an input request on a handshake connection raises the mirror-image error rather than misbehaving quietly. + +The shortest way forward, and the only one that leaves you with a single code path, is to stop calling `ctx.elicit()` and **declare the value as a parameter** instead. FastMCP then picks the mechanism for whichever era the connection negotiated, so the same tool serves both: + +```python +from typing import Annotated + +from fastmcp import FastMCP +from fastmcp.elicitation import Elicit + +mcp = FastMCP("Booking Server") + + +# before — works on handshake, raises on modern +@mcp.tool +async def book_flight_v3(ctx) -> str: + result = await ctx.elicit("Where would you like to fly?", response_type=str) + return f"Booked {result.data}" if result.action == "accept" else "Cancelled" + + +# after — works on both +@mcp.tool +async def book_flight( + destination: Annotated[str, Elicit("Where would you like to fly?")], +) -> str: + return f"Booked {destination}" +``` + +Declining is handled by the parameter's default rather than by branching on `result.action`: give it one and a decline leaves the default in place, omit it and a decline fails the call. + +Failing that, there are three more options. Rewrite the tool as a guard tool that *returns* a description of the input it needs, which is worth doing when the question depends on expensive work whose result has to stay stable across the round trip. Branch on `ctx.request_context.protocol_version` and keep both paths. Or keep this server's clients on the handshake era with `Client(server, mode="legacy")`, which leaves `ctx.elicit()` working as written. See [Elicitation](/servers/elicitation#which-approach-to-use) for all of them side by side. **Middleware sees traffic it never saw before.** Dispatch now begins in the SDK's middleware layer, the single point every inbound message passes through, so `on_message`, `on_request`, and `on_notification` observe *every* message a client sends — including `notifications/cancelled`, `notifications/initialized`, and `notifications/progress`, and including requests that fail before reaching a handler, such as an unknown method or a `tools/call` whose params fail validation. In 3.x those never reached your hooks. Middleware that assumed every message it saw was a routable request, or that counted messages to measure tool traffic, needs a guard on the message type. The operation hooks (`on_call_tool`, `on_list_tools`, and the rest) are unaffected: they still fire exactly once per request and still receive typed component results. See [What middleware sees](/servers/middleware#what-middleware-sees). @@ -423,7 +453,7 @@ Migrating differs by capability. For **roots**, the guard pattern is the direct | --- | --- | --- | | `ctx.info` / logging notifications | Supported | Supported | | Tools, resources, prompts, completions | Supported | Supported | -| `ctx.elicit` | Supported | Raises — use the guard pattern (return `InputRequiredResult`) | +| `ctx.elicit` | Supported | Raises — declare the value with `Elicit`, or use the guard pattern | | `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 | @@ -431,7 +461,7 @@ Migrating differs by capability. For **roots**, the guard pattern is the direct | 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 | -Several 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; each request is a fresh connection, so state written with `ctx.set_state` in one call is not visible in the next; and a tool that calls [`ctx.elicit()`](#behavior-changes) raises. A server that gates access in `on_initialize`, relies on per-session state, or elicits mid-tool must keep its clients on the session-based era. The control is per-client: `Client(server, mode="legacy")`. There is no server-side setting that restricts which protocol versions a server offers, so a server whose behavior depends on the handshake era depends on its callers opting into it — which is only practical when you control them. If you don't, port the behavior instead: a guard tool for elicitation, [session state](/servers/sessions) for what `ctx.set_state` held, and per-request auth checks for what `on_initialize` gated. +Several 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; each request is a fresh connection, so state written with `ctx.set_state` in one call is not visible in the next; and a tool that calls [`ctx.elicit()`](#behavior-changes) raises. A server that gates access in `on_initialize`, relies on per-session state, or elicits mid-tool must keep its clients on the session-based era. The control is per-client: `Client(server, mode="legacy")`. There is no server-side setting that restricts which protocol versions a server offers, so a server whose behavior depends on the handshake era depends on its callers opting into it — which is only practical when you control them. If you don't, port the behavior instead: [declared parameters](/servers/elicitation#declared-parameters) or a guard tool for elicitation, [session state](/servers/sessions) for what `ctx.set_state` held, and per-request auth checks for what `on_initialize` gated. 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. @@ -443,7 +473,7 @@ Most servers upgrade untouched. Work down this list to find the ones that don't: 2. **Fix imports that moved out.** `from mcp.types import X` still works, but 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). 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. **Find every `ctx.elicit()` call.** It raises on modern connections, which is what a default client now negotiates. Rewrite the tool as a guard tool, branch on `ctx.request_context.protocol_version`, or keep its clients on `mode="legacy"` — see [the era gate](#behavior-changes). +5. **Find every `ctx.elicit()` call.** It raises on modern connections, which is what a default client now negotiates. Declaring the value as a parameter is the shortest fix and the only one that serves both eras from one code path; a guard tool, branching on `ctx.request_context.protocol_version`, or pinning clients to `mode="legacy"` also work — see [the era gate](#behavior-changes). 6. **Register the tasks extension.** A `task=True` tool needs `mcp.add_extension(TasksExtension())` or the server won't start. Drop `task=` from resource and prompt decorators, move `TaskConfig` to `fastmcp.utilities.tasks`, and replace client-side `call_tool(..., task=True)` with plain `call_tool` or `call_tool_task`. 7. **Update removed tool parameters.** Replace tool `serializer=` (return a `ToolResult`), `exclude_args=` (use `Depends()`), and `StreamableHttpTransport(sse_read_timeout=)`. 8. **Fix `McpError` construction.** Positional `McpError(ErrorData(...))` becomes keyword `McpError(code=..., message=...)`. Catching is unchanged. diff --git a/docs/getting-started/upgrading/from-mcp-sdk-v2.mdx b/docs/getting-started/upgrading/from-mcp-sdk-v2.mdx index e25489005..ea6e74fc5 100644 --- a/docs/getting-started/upgrading/from-mcp-sdk-v2.mdx +++ b/docs/getting-started/upgrading/from-mcp-sdk-v2.mdx @@ -81,10 +81,10 @@ These four exist on both but with DIFFERENT signatures, so a bare import swap co Genuinely unchanged: `report_progress`, `request_id`, `client_id`, `input_responses`, `request_state`, `session`, and `request_context`. -RESOLVERS — the one part that is not a rename, so check for it first +RESOLVERS — elicitation resolvers rename; sampling and roots resolvers do not - any `Annotated[T, Resolve(fn)]` parameter, and the resolvers behind it - resolvers returning `Elicit[...]`, `Sample`, or `ListRoots` -FastMCP has no resolver injection, but the underlying requests survive in a different shape: on a modern connection `Elicit`, `Sample`, and `ListRoots` all ride the guard pattern, where the tool returns an `InputRequiredResult` and the client answers on the next call. Do not tell the user these capabilities are simply unavailable. Flag every resolver with the guide's per-capability reasoning (server-side LLM call is usually better than guard-routed sampling; roots are often simplest as ordinary tool arguments) rather than picking a rewrite yourself. Also note that a resolved parameter is hidden from the tool's input schema, so replacing it with an ordinary argument changes the schema clients see. +Resolvers that elicit port almost directly: FastMCP spells it `Annotated[T, Elicit(...)]`, the resolver still returns `T | Elicit[T]`, and the parameter is still hidden from the input schema. Report those as a rename, noting that the type moves to the annotation and that declining is handled by the parameter's default. Resolvers returning `Sample` or `ListRoots` have no injected equivalent — the underlying requests survive on the guard pattern, where the tool returns an `InputRequiredResult` and the client answers on the next call. Do not tell the user those capabilities are unavailable; flag each with the guide's per-capability reasoning (a server-side LLM call is usually better than guard-routed sampling; roots are often simplest as ordinary tool arguments) rather than picking a rewrite yourself. For each item found, show the original code, name what changed, and give the FastMCP equivalent from the guide. Call out anything you could not find a documented replacement for instead of inventing one. @@ -264,7 +264,7 @@ For most servers this is an improvement that costs nothing — a caller sending ## Asking for Input -This is the one part of the migration that is not a rename, so read it before you start if your tools use resolvers. +Elicitation resolvers are close to a rename; sampling and roots resolvers are not. Read this before you start if your tools use resolvers. `MCPServer` asks the client for things through dependency-injection resolvers. A tool parameter annotated `Annotated[T, Resolve(fn)]` is filled by running `fn` before the tool body, and the resolver can return a request marker — `Elicit[T]` to ask the user, `Sample` to borrow the client's model, `ListRoots` to fetch its roots — which the framework turns into the right wire interaction for whichever protocol era the connection negotiated: @@ -290,30 +290,45 @@ def book_flight(dest: Annotated[Destination, Resolve(ask_destination)]) -> str: return f"Booked to {dest.destination}" ``` -FastMCP has no equivalent annotation, and it makes the protocol era explicit instead of hiding it. Which replacement you want depends on which era your clients speak. - -On **handshake-era connections** (≤ 2025-11-25), a running tool asks the user directly with `ctx.elicit()`, and the call blocks until the answer arrives. Where the resolver returned a value or aborted the call, `ctx.elicit()` hands you the outcome to branch on, so declining and cancelling become cases your tool answers for itself: +Resolvers that ask the user port almost directly. FastMCP spells the annotation `Annotated[T, Elicit(...)]`, and the same rule applies — the parameter is filled before the body runs, it is hidden from the tool's input schema, and the framework picks the wire interaction for whichever era the connection negotiated: ```python -from fastmcp import FastMCP, Context +from typing import Annotated + +from pydantic import BaseModel + +from fastmcp import FastMCP +from fastmcp.elicitation import Elicit mcp = FastMCP("booking") +class Destination(BaseModel): + destination: str + + @mcp.tool -async def book_flight(ctx: Context) -> str: +def book_flight(dest: Annotated[Destination, Elicit("Where would you like to fly?")]) -> str: """Book a flight""" - result = await ctx.elicit("Where would you like to fly?", response_type=str) - if result.action == "accept": - return f"Booked to {result.data}" - return "Booking cancelled" + return f"Booked to {dest.destination}" ``` -On the **modern protocol** (2026-07-28), server-initiated requests are gone from the wire, so a tool asks by *returning* a description of what it needs. The client answers and calls the tool again with the answer attached, and the tool re-runs from the top. This is the [guard pattern](/servers/elicitation#elicitation-on-the-modern-protocol), and it reads the answers off `ctx.input_responses`. +Two differences are worth knowing. The type comes from the annotation rather than from a second argument to `Elicit(...)`, so it is stated once — `Elicit("...", response_type=...)` exists for the case where you build one inside a resolver and the annotation is out of view. And a fixed question needs no resolver function at all; pass the string directly. -The two are era-gated in both directions: `ctx.elicit()` raises on a modern connection, and a guard result raises on a handshake one. A server that must serve both branches on `ctx.request_context.protocol_version`. See [Elicitation](/servers/elicitation#which-approach-to-use) for both shapes side by side. +Where the SDK's resolver did real work, pass a function instead. It returns `T | Elicit[T]`, which is the same contract as the SDK's — return an `Elicit` to ask, return a value to skip asking: -Resolvers that return `Sample` or `ListRoots` have no *injected* equivalent — FastMCP has no `ctx.sample()` or `ctx.list_roots()` — but the underlying request survives, so this is a change of shape rather than a loss of capability. On a modern connection both ride the same guard pattern as elicitation: the tool returns an `InputRequiredResult` describing the sampling or roots request, and the client answers on the next call. +```python +def which_airport(destination: str) -> str | Elicit[str]: + if destination == "London": + return "LHR" + return Elicit(f"Which airport in {destination}?", response_type=str) +``` + +Declining is handled by the parameter's default rather than by annotating `ElicitationResult[T]`: give the parameter a default and a decline leaves it in place, omit one and a decline fails the call. + +If a question depends on expensive or non-deterministic work whose result has to stay fixed while the user answers, use the [guard pattern](/servers/elicitation#elicitation-on-the-modern-protocol) instead — the tool returns an `InputRequiredResult` and carries what it computed in `request_state`. Declared parameters re-resolve on every round, so a live search runs again and can return something different. That pattern is modern-only; `ctx.elicit()` is handshake-only; declared parameters serve both. See [Elicitation](/servers/elicitation#which-approach-to-use). + +Resolvers that return `Sample` or `ListRoots` are the ones with no injected equivalent — FastMCP's `Elicit` covers elicitation only, and there is no `ctx.sample()` or `ctx.list_roots()` — but the underlying request survives, so this is a change of shape rather than a loss of capability. On a modern connection both ride the same guard pattern as elicitation: the tool returns an `InputRequiredResult` describing the sampling or roots request, and the client answers on the next call. Which shape you want differs by capability. For **roots**, the guard route is the natural replacement, since one round buys the whole answer — and taking the paths as ordinary tool arguments is simpler still whenever the caller can supply them. For **generation**, prefer [calling an LLM from your server](/servers/sampling) with your own API key: your tool then behaves identically for every client, including the many that never implemented sampling, and you avoid paying a full request-response cycle per generation step. Reach for the guard route when using the *caller's* model is specifically the point. diff --git a/docs/getting-started/whats-new.mdx b/docs/getting-started/whats-new.mdx index b2dfffb8d..7a4988778 100644 --- a/docs/getting-started/whats-new.mdx +++ b/docs/getting-started/whats-new.mdx @@ -25,7 +25,7 @@ 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. Once connected, `client.protocol_version`, `client.server_info`, `client.server_capabilities`, and `client.instructions` read the same regardless of which era you negotiated — code that inspects the connection no longer branches on how it got there. 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), 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. +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. Better still, a tool can stop managing that exchange at all and simply declare which of its parameters come from the user — `Annotated[str, Elicit("Where would you like to fly?")]` is filled before the body runs, hidden from the tool's schema, and works unchanged on both protocol eras, because the framework rather than your code decides how the question travels. See [Elicitation](/servers/elicitation#declared-parameters). `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. diff --git a/docs/more/faq.mdx b/docs/more/faq.mdx index 29b0f7e89..12b9944d5 100644 --- a/docs/more/faq.mdx +++ b/docs/more/faq.mdx @@ -72,9 +72,11 @@ Receiving-side filtering only narrows what already arrives. A server that sets ` ## What replaces elicitation on the modern protocol? -The guard pattern. Rather than pausing mid-execution to ask, a tool *returns* an `InputRequiredResult` describing what it needs. That round completes normally, the client collects the answer, and it calls the tool again with the answer attached. Any state you carry between rounds is sealed by the framework before it reaches the wire, so the client holds an opaque token it cannot read or forge. +Declaring the value as a parameter. `Annotated[str, Elicit("Where would you like to fly?")]` is filled by asking the user before the body runs, and it is the only shape that works unchanged on both eras — the framework picks how the question travels, so your tool never branches on the protocol. -`ctx.elicit()` still works on handshake-era connections and raises on modern ones, so a server that must serve both eras needs both paths. `fastmcp.Client` drives whichever the connection negotiated with no extra wiring on your side. See [Elicitation on the modern protocol](/servers/elicitation#elicitation-on-the-modern-protocol). +Underneath, the modern era uses the guard pattern: rather than pausing mid-execution to ask, a tool *returns* an `InputRequiredResult` describing what it needs. That round completes normally, the client collects the answer, and calls again with the answer attached. Any state carried between rounds is sealed by the framework before it reaches the wire, so the client holds an opaque token it cannot read or forge. Write that by hand when a question depends on expensive work whose result has to stay fixed while the user answers. + +`ctx.elicit()` still works on handshake-era connections and raises on modern ones, so a server using it that must serve both eras needs both paths — which is the thing declared parameters exist to avoid. `fastmcp.Client` drives whichever the connection negotiated with no extra wiring on your side. See [Elicitation](/servers/elicitation#which-approach-to-use). ## Why doesn't my middleware's `on_initialize` hook run? diff --git a/docs/servers/context.mdx b/docs/servers/context.mdx index 01a5372d3..6b95fede0 100644 --- a/docs/servers/context.mdx +++ b/docs/servers/context.mdx @@ -149,6 +149,19 @@ if result.action == "accept": name = result.data ``` +`ctx.elicit()` only works on handshake-era connections, because it pushes a request down a live connection. To ask for something on any era, [declare it as a parameter](/servers/elicitation#declared-parameters) instead and let FastMCP choose how the question travels: + +```python +from typing import Annotated + +from fastmcp.elicitation import Elicit + + +@mcp.tool +async def greet(name: Annotated[str, Elicit("Enter your name:")]) -> str: + return f"Hello, {name}" +``` + See [User Elicitation](/servers/elicitation) for detailed examples and supported response types. ### Sampling and Roots