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 1cd484680..126bf923e 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 cebc3e682..9621ed4c3 100644 --- a/docs/getting-started/whats-new.mdx +++ b/docs/getting-started/whats-new.mdx @@ -35,6 +35,8 @@ legacy = Client("https://example.com/mcp", mode="legacy") Once connected, `client.protocol_version`, `client.server_info`, `client.server_capabilities`, and `client.instructions` expose the same interface whichever era was negotiated. Application code that inspects a server does not need a protocol-version branch. 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. 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. + On modern connections, FastMCP also attaches the method, target name, and opted-in argument values as HTTP headers. Gateways and load balancers can route requests without parsing JSON-RPC bodies. See [Gateway routing headers](/deployment/http#gateway-routing-headers). ## Stateful applications diff --git a/docs/more/faq.mdx b/docs/more/faq.mdx index 5566e908f..e08d686a8 100644 --- a/docs/more/faq.mdx +++ b/docs/more/faq.mdx @@ -80,9 +80,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 667ce9764..f98bd7e79 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 diff --git a/docs/servers/elicitation.mdx b/docs/servers/elicitation.mdx index eacde0b60..c3b22cea6 100644 --- a/docs/servers/elicitation.mdx +++ b/docs/servers/elicitation.mdx @@ -1,7 +1,7 @@ --- title: User Elicitation sidebarTitle: Elicitation -description: Ask users for input while a tool is running, on both the handshake and modern protocols. +description: Ask users for input from a tool — by declaring what you need, or by driving the exchange yourself. icon: message-question --- @@ -9,9 +9,9 @@ import { VersionBadge } from '/snippets/version-badge.mdx' -User elicitation allows MCP servers to request input from users during tool execution. Instead of requiring all inputs upfront, tools can interactively ask for missing parameters, clarification, or additional context as needed. +User elicitation lets an MCP server ask a person for input in the course of a tool call, rather than requiring everything up front. Some of what a tool needs is not the model's to supply — which directory, which date range, whether to go ahead — and elicitation is how the tool gets it from the user instead. -Elicitation enables tools to request specific information from users mid-task: +Elicitation covers a familiar set of needs: - **Missing parameters**: Ask for required information not provided initially - **Clarification requests**: Get user confirmation or choices for ambiguous scenarios @@ -22,12 +22,232 @@ For example, a file management tool might ask "Which directory should I create?" ## Which approach to use -Elicitation reaches the user two different ways, depending on the protocol era the connection negotiated: +How an ask reaches the user depends on the protocol era the connection negotiated. Handshake-era connections (≤ 2025-11-25) have a session back-channel, so a running tool can send a request and block on the answer. The modern protocol (2026-07-28) removed server-initiated requests from the wire (SEP-2577), so there is no mid-execution channel at all — an ask has to *be* the result of the call, which the client answers before calling again. -- **On handshake-era connections (≤ 2025-11-25)**, a running tool calls [`ctx.elicit()`](#requesting-input-on-handshake-connections). The tool pauses mid-execution, the server sends a request over the session back-channel, and the tool resumes with the answer. This is the original elicitation API and the rest of this page's first half covers it in full. -- **On the modern protocol (2026-07-28)**, that back-channel is gone — server-initiated requests were removed from the wire (SEP-2577), so a tool cannot issue a request mid-execution and block on the answer. Instead a tool asks for input by *returning* a description of what it needs; each round completes normally and the client issues a new call with the answer attached. This is the [guard pattern](#elicitation-on-the-modern-protocol), covered in the second half. +That difference is the thing to reason about, and you can either let FastMCP handle it or handle it yourself. -The era gate is strict: `ctx.elicit()` only works on handshake connections, and the guard pattern only works on modern ones. A tool that returns a guard result on a handshake connection — or calls `ctx.elicit()` on a modern one — raises a clear era error rather than failing obscurely. A server that serves both eras may need both paths; branch on `ctx.request_context.protocol_version` to pick the right one. `fastmcp.Client` drives whichever the connection negotiated automatically. +**[Declare what you need](#declared-parameters)** and FastMCP asks for it. A parameter annotated `Annotated[T, Elicit(...)]` is filled by asking the user rather than by the model, and the framework selects the transport for whichever era the connection negotiated. This is the recommended approach and the only one that works unchanged on both. + +**[Ask imperatively with `ctx.elicit()`](#requesting-input-on-handshake-connections)** to reach the user from inside a running tool. This is the original elicitation API, and it works only on handshake-era connections, where the back-channel exists. + +**[Drive the rounds from the tool body](#elicitation-on-the-modern-protocol)** by returning an `InputRequiredResult`. This works only on the modern protocol, and it earns its extra complexity when the question depends on expensive or non-deterministic work whose result has to stay stable across a round trip. + +The era gate on the two manual approaches is strict, and deliberately so: calling `ctx.elicit()` on a modern connection, or returning an `InputRequiredResult` on a handshake one, raises a clear era error rather than failing obscurely. Declared parameters are never subject to that gate, because the framework is the one choosing. `fastmcp.Client` drives whichever mechanism the connection negotiated automatically. + +## Declared parameters + + + +A tool's parameters describe what it needs to run. Most of them are filled by the model calling the tool, but some are things only a person can answer — which airport, which file, whether to proceed. Annotating a parameter with `Elicit` says that this one comes from the user, and FastMCP fills it before the body runs. + +```python +from typing import Annotated + +from fastmcp import FastMCP +from fastmcp.elicitation import Elicit + +mcp = FastMCP("Booking Server") + + +@mcp.tool +async def book_flight( + destination: Annotated[str, Elicit("Where would you like to fly?")], +) -> str: + return f"Booked a flight to {destination}" +``` + +The body reads like an ordinary function because it is one. By the time it runs, `destination` holds a real string; there is no `Context`, no result union, and no branching on which round this is. FastMCP asks the question, collects the answer, and calls the function — over a mid-execution request on handshake connections, or across a round trip on modern ones, without the function changing. + +An elicited parameter is also hidden from the tool's input schema. The model calling `book_flight` sees a tool that takes no arguments, which is accurate: it is not the one supplying the destination. Everything you already know about [schemas and response types](#schema-and-response-types) applies to the annotated type, so scalars, `Literal`s, enums, dataclasses, and Pydantic models all behave exactly as they do with `ctx.elicit()`. + +Ask for more by annotating more parameters. What happens then is worth knowing: FastMCP looks at what each question needs and sends out everything it can answer at once, so a tool that wants a destination and a date asks for both in a single round rather than making two trips to the client and back. Nothing in your code requests that. It follows from the two questions not referring to each other, which is something the framework can see in the annotations and a person writing the exchange by hand has to remember — which is why hand-written versions almost always ask one at a time, in whatever order they were written. + +### Dependent questions + +A fixed string is the right question only when it is always the right question. Usually it stops being one as soon as you know something: once the traveller has said Paris, the useful thing to ask is not "which airport?" but "CDG or ORY?". + +Pass a function instead of a string and you get a **resolver** — something that runs when the parameter needs filling and decides what to do about it. A resolver returns `T | Elicit[T]`: an `Elicit` is a question to put to the user, and a plain value is the answer already known, in which case nobody is asked at all. + +Its parameters are filled by name — from the tool's own arguments, from other elicited parameters, or both. That name-matching does double duty. It supplies the values, and it establishes the order: a question that quotes an answer nobody has given yet cannot be written, so it waits for the round that produces it, while every question independent of it still goes out immediately. + +```python +from typing import Annotated + +from fastmcp import FastMCP +from fastmcp.elicitation import Elicit + +mcp = FastMCP("Booking Server") + + +def which_airport(destination: str) -> Elicit[str]: + return Elicit(f"Which airport in {destination} — CDG or ORY?", response_type=str) + + +@mcp.tool +async def book_flight( + destination: Annotated[str, Elicit("Where would you like to fly?")], + airport: Annotated[str, Elicit(which_airport)], +) -> str: + return f"Booked into {airport}" +``` + +So this tool takes two rounds, and neither the round count nor the ordering appears anywhere in the code. Get the wiring wrong — name a value the tool does not have, write two questions that each wait on the other, or declare a resolver that elicits a type its parameter cannot hold — and FastMCP rejects the tool when it is registered, at import time, rather than on the first call in production. + +### Questions worth skipping + +Returning a value rather than an `Elicit` is how a resolver declines to ask. Most of the time you are asking because you genuinely do not know, but plenty of questions have an answer sitting somewhere already — on the user's profile, in an argument the model supplied, in a table with one row: + +```python +from typing import Annotated + +from pydantic import BaseModel + +from fastmcp import FastMCP +from fastmcp.dependencies import Depends +from fastmcp.elicitation import Elicit + +mcp = FastMCP("Booking Server") + + +class Profile(BaseModel): + home_airport: str | None = None + + +def current_profile() -> Profile: + return Profile(home_airport="LHR") + + +def which_airport(destination: str, profile: Profile = Depends(current_profile)) -> str | Elicit[str]: + if profile.home_airport: + return profile.home_airport + return Elicit(f"Which airport in {destination}?", response_type=str) + + +@mcp.tool +async def book_flight( + destination: str, + airport: Annotated[str, Elicit(which_airport)], +) -> str: + return f"Booked {destination} from {airport}" +``` + +A returning traveller is never asked and never pays a round trip; a new one gets the question. The tool body is identical either way, and so is the annotation — only the resolver knows the difference. + +An `Elicit` takes the same `message` and `response_type` as [`ctx.elicit()`](#requesting-input-on-handshake-connections), because it describes the same thing. The only difference is where it goes — you `await` the imperative one and `return` this one: + +```python +result = await ctx.elicit(message="Which airport?", response_type=Airport) # imperative +return Elicit(message="Which airport?", response_type=Airport) # declarative +``` + +State `response_type` when you build an `Elicit` inside a resolver. The parameter's annotation is two functions away at that point, and repeating it locally is worth more than the brevity of leaving it out; omit it and the parameter's annotation is used. When a resolver also declares the type in its return — `-> str | Elicit[str]` — FastMCP checks the two agree at registration. + +A question that has already been answered is not asked again. Resolvers re-run on every round, so a three-round call re-forms all its earlier questions, but each one is satisfied by the answer recorded against it rather than put to the user a second time. That holds for every `Elicit`, whether it came from a literal or a resolver — as long as the question still renders the same way, which is what the [digest](#repeated-questions) checks. + +Because a question can quote a tool argument, it can also quote something the model supplied — which is exactly what you want for `f"Which airport in {destination}?"`, and a good reason to treat the wording as untrusted display text rather than as an instruction to the user. + +There is no separate knob for ordering, and you rarely want one. A question that has to wait for another almost always has something to say about it, so saying it is both the better question and the thing that orders the asks. Confirmations show this most clearly. `Elicit("Book it?")` refers to nothing, so it goes out in the very first round, asking someone to approve a booking that nobody has described yet. Written as a function it quotes the details, which fixes the wording and the timing at once: + +```python +from typing import Annotated + +from fastmcp import FastMCP +from fastmcp.elicitation import Elicit + +mcp = FastMCP("Booking Server") + + +def confirm(destination: str, date: str) -> Elicit[bool]: + return Elicit(f"Book a flight to {destination} on {date}?", response_type=bool) + + +@mcp.tool +async def book_flight( + destination: Annotated[str, Elicit("Where would you like to fly?")], + date: Annotated[str, Elicit("When would you like to fly?")], + proceed: Annotated[bool, Elicit(confirm)], +) -> str: + return f"Booked {destination}" if proceed else "Cancelled" +``` + +Where and when both fall out of the same annotations. The destination and date are independent, so they go out together in the first round; the confirmation quotes both, so it waits for the second. Questions that stay independent are asked in the order they appear in the signature, so moving a parameter down moves its question down in what the user sees — though it is the client that ultimately decides how to present a round. + +If you ever find yourself adding a parameter to a question function purely to hold it back, treat that as a sign the question is underspecified rather than a technique. It works, and it costs a round trip to ask something you could have asked earlier. + +A question may also declare its own [dependencies](/servers/dependency-injection) with `Depends(...)`, for the configuration and connections it needs to render itself. Those resolve the ordinary way and are not matched against the call's arguments. + +### Optional questions + +Users say no. Sometimes that has to stop everything, because there is no booking without a destination. Sometimes it should barely register — a seat preference is worth asking about, and the flight leaves either way. + +FastMCP tells those apart by reading the signature, using the distinction Python already has. Ask for a parameter with no default and you are saying the call cannot go on without it, so declining fails the call with an error naming the parameter. Give it a default and you are saying the opposite: a decline leaves the default in place and the body runs. Cancelling behaves the same way as declining, since both mean the same thing to you — no answer is coming. + +```python +from typing import Annotated + +from fastmcp import FastMCP +from fastmcp.elicitation import Elicit + +mcp = FastMCP("Booking Server") + + +@mcp.tool +async def book_flight( + destination: Annotated[str, Elicit("Where would you like to fly?")], + seat: Annotated[str | None, Elicit("Window or aisle?")] = None, +) -> str: + preference = seat or "no preference" + return f"Booked to {destination} ({preference})" +``` + +There is nothing to learn here beyond what you already know about Python, which is the point: anyone reading this signature can see which question the booking depends on and which one it can shrug off, without knowing anything about elicitation. + +### Repeated questions + +Answers do not float free of the questions that produced them. Each one is recorded against the exact text the user was shown, so it can only ever satisfy the question it actually answered. + +That guard earns its keep on the modern protocol, where a call spans rounds and earlier answers travel back and forth with the request. Deploy reworded copy in the middle of someone's booking, or retry a call with a different argument feeding one of the questions, and the wording shifts underneath them — so FastMCP drops the stale answer and asks again rather than crediting someone with an answer to a question they were never shown. There is nothing to configure. It is worth knowing about because it explains the one behaviour that surprises people: a question you expected to be remembered coming back around. + +### Expensive questions + +A question function is ordinary Python, so it can do real work to build itself — query a database, call an API, format what comes back into the text the user reads. That is genuinely useful, and it is where this approach has its one sharp edge. + +The edge is timing. Declared parameters resolve before the body runs, and on the modern protocol a call spans several rounds with parameters resolving on every one of them. A question that runs a search to build itself runs that search again on the round that answers it — and the second search can return something different from the first. + +Watch for it in this tool, which offers the traveller a list of flights: + +```python +from typing import Annotated + +from fastmcp import FastMCP +from fastmcp.elicitation import Elicit + +mcp = FastMCP("Booking Server") + + +def search_flights(destination: str, date: str) -> list[str]: + return [f"AF{number} to {destination} on {date}" for number in (100, 200)] + + +def which_flight(destination: str, date: str) -> Elicit[str]: + options = search_flights(destination, date) + return Elicit(f"Which flight? {', '.join(options)}", response_type=str) + + +@mcp.tool +async def book_flight( + destination: str, + date: str, + choice: Annotated[str, Elicit(which_flight)], +) -> str: + return f"Booked {choice}" +``` + +With `search_flights` as written the repeat is harmless, because it returns the same two flights every time. Point it at a real airline and the story changes: you offer three flights, the traveller picks the first, and by the round that delivers their answer the search no longer lists it. They have chosen something that is gone, and the tool has no way to notice. + +So the line to draw is about the *work*, not the question. Declare the parameter when the question is built from the tool's inputs and cheap, repeatable derivations of them — the overwhelming majority of cases. When the question depends on work that is expensive to repeat or whose result has to stay fixed while the user thinks about it, [drive the rounds from the body](#elicitation-on-the-modern-protocol) instead. There you run the search once, put its result in `request_state`, and read it back on the next round, which is exactly the machinery that keeps the offer and the answer talking about the same thing. + +Within a single tool the two approaches are mutually exclusive: a call has one channel for gathering input, so declaring `Elicit` parameters *and* returning an `InputRequiredResult` would have them overwrite each other's state. FastMCP rejects that combination when the tool is registered rather than letting it fail to converge at run time. ## Requesting input on handshake connections @@ -393,10 +613,12 @@ Default values are supported for strings, integers, numbers, booleans, and enums The modern protocol (2026-07-28) removes the server-initiated back-channel that `ctx.elicit()` depends on (SEP-2577), so a running tool has no way to reach the user mid-execution. Elicitation reaches the user a different way: a tool asks for input by *returning* a description of what it needs. That return value completes the call normally — the result just happens to be an `InputRequiredResult` describing a request rather than a final answer. The client fulfils the request and issues a **new** tool call with the answer attached, and the tool runs again from the top, sees the answer, and either asks for the next thing or returns its final result. +This is the mechanism [declared parameters](#declared-parameters) use on modern connections, and reaching for it directly means taking the wheel. Do that when the question depends on expensive or non-deterministic work — a live search, a quote, a reserved identifier — whose result has to stay stable while the user answers. Driving the rounds yourself is what lets you compute once and carry the result forward, rather than recomputing it on every leg. For questions built from the tool's inputs and cheap derivations of them, declaring the parameter is shorter and works on both eras. + Every round is a complete, independent request→response cycle: the tool holds no state between rounds, and nothing on the server stays alive waiting between them. That makes elicitation work on stateless, serverless, and load-balanced deployments where no two rounds are guaranteed to land on the same worker. A booking tool can ask for a destination, then a date, then confirm, across as many rounds as the work requires, without keeping a connection or a server-side session alive in between. -This pattern requires an MCP **2026-07-28** connection. The `InputRequiredResult` result type does not exist on earlier protocol versions; a tool that returns one on a handshake-era connection raises a clear error (see [Protocol requirements](#protocol-requirements)). On those connections, use [`ctx.elicit()`](#requesting-input-on-handshake-connections) instead. +This pattern requires an MCP **2026-07-28** connection. The `InputRequiredResult` result type does not exist on earlier protocol versions; a tool that returns one on a handshake-era connection raises a clear error (see [Protocol requirements](#protocol-requirements)). On those connections, use [`ctx.elicit()`](#requesting-input-on-handshake-connections) — or [declare the parameter](#declared-parameters), which serves both eras from one definition. ### How it works @@ -539,7 +761,7 @@ connection negotiated '2025-11-25'. Use ctx.elicit() for server-initiated input on handshake-era connections. ``` -If you need to support both eras, branch on `ctx.request_context.protocol_version`: return an `InputRequiredResult` on modern connections and fall back to [`ctx.elicit()`](#requesting-input-on-handshake-connections) on handshake-era ones. +To support both eras from one tool, [declare the parameter](#declared-parameters) and let FastMCP pick the mechanism. Driving the exchange by hand means writing both paths and branching on `ctx.request_context.protocol_version`: return an `InputRequiredResult` on modern connections and fall back to [`ctx.elicit()`](#requesting-input-on-handshake-connections) on handshake-era ones. ### Prompts and resources diff --git a/fastmcp_slim/fastmcp/client/client.py b/fastmcp_slim/fastmcp/client/client.py index ae1dde726..2d9e69605 100644 --- a/fastmcp_slim/fastmcp/client/client.py +++ b/fastmcp_slim/fastmcp/client/client.py @@ -250,13 +250,20 @@ def _connection_failure(exception: BaseException) -> BaseException: @dataclass class CallToolResult: - """Parsed result from a tool call.""" + """Parsed result from a tool call. + + A call that asked for input rather than completing carries the ask on + `input_required` and nothing else — `content` is empty and `data` is None. + That only happens when the caller passed `allow_input_required=True`; by + default the client resolves the exchange before returning. + """ content: list[mcp_types.ContentBlock] structured_content: dict[str, Any] | None meta: dict[str, Any] | None data: Any = None is_error: bool = False + input_required: mcp_types.InputRequiredResult | None = None class Client( diff --git a/fastmcp_slim/fastmcp/client/mixins/tools.py b/fastmcp_slim/fastmcp/client/mixins/tools.py index 40db1f21c..55ba5196f 100644 --- a/fastmcp_slim/fastmcp/client/mixins/tools.py +++ b/fastmcp_slim/fastmcp/client/mixins/tools.py @@ -143,12 +143,42 @@ class ClientToolsMixin: progress_handler: ProgressHandler | None = None, timeout: datetime.timedelta | float | int | None = None, meta: dict[str, Any] | None = None, + *, + input_responses: mcp_types.InputResponses | None = None, + request_state: str | None = None, + _return_ask: bool = False, ) -> mcp_types.CallToolResult: """Send a tools/call request and return the complete MCP protocol result. This method returns the raw CallToolResult object, which includes an isError flag and other metadata. It does not raise an exception if the tool call results in an error. + A tool that asks for client input answers with an `InputRequiredResult` + (SEP-2322) rather than a final result. By default that is resolved for + you, the same way `call_tool` does it — each embedded request is + dispatched to this client's handlers and the call is retried until it + A tool that needs input from the user answers with an + `InputRequiredResult` (SEP-2322) rather than a final result. What happens + next depends on whether this client was given an `elicitation_handler`: + with one, each question is put to it and the call is retried until it + completes; without one, the request is returned to you, and you answer it + by calling again with `input_responses` and the `request_state` it + carried. + + ```python + async with Client(mcp) as client: + ask = await client.call_tool("book") + answers = { + key: mcp_types.ElicitResult(action="accept", content={"value": "Paris"}) + for key in ask.input_required.input_requests + } + final = await client.call_tool( + "book", + input_responses=answers, + request_state=ask.input_required.request_state, + ) + ``` + Args: name (str): The name of the tool to call. arguments (dict[str, Any]): Arguments to pass to the tool. @@ -160,8 +190,10 @@ class ClientToolsMixin: can access this via `context.request_context.meta`. Defaults to None. Returns: - mcp_types.CallToolResult: The complete response object from the protocol, - containing the tool result and any additional metadata. + The complete response object from the protocol. An + `InputRequiredResult` when the tool asked for input and + mcp_types.CallToolResult: The complete response object from the + protocol, containing the tool result and any additional metadata. Raises: RuntimeError: If called while the client is not connected. @@ -214,7 +246,14 @@ class ClientToolsMixin: allow_claimed=has_claims, ) - first = await self._await_with_session_monitoring(_retry(None, None)) + first = await self._await_with_session_monitoring( + _retry(input_responses, request_state) + ) + if _return_ask and isinstance(first, mcp_types.InputRequiredResult): + # Internal contract with `call_tool`, which sets `_return_ask` + # when this client has no handler and narrows the result back + # out. Public callers never see anything but a CallToolResult. + return cast("mcp_types.CallToolResult", first) driven = await self._await_with_session_monitoring( self._drive_input_required(first, _retry) ) @@ -281,6 +320,8 @@ class ClientToolsMixin: progress_handler: ProgressHandler | None = None, raise_on_error: bool = True, meta: dict[str, Any] | None = None, + input_responses: mcp_types.InputResponses | None = None, + request_state: str | None = None, ) -> CallToolResult: """Call a tool on the server. @@ -325,7 +366,23 @@ class ClientToolsMixin: timeout=timeout, progress_handler=progress_handler, meta=request_meta or None, + input_responses=input_responses, + request_state=request_state, + # With no handler there is nothing here that can answer, so ask for + # the question itself instead of failing. + _return_ask=self._elicitation_callback is None, ) + if isinstance(result, mcp_types.InputRequiredResult): + # The caller is driving; hand the ask back rather than parsing it as + # tool output, which it is not. + from fastmcp.client.client import CallToolResult + + return CallToolResult( + content=[], + structured_content=None, + meta=None, + input_required=result, + ) return await self._parse_call_tool_result( name, result, raise_on_error=raise_on_error ) diff --git a/fastmcp_slim/fastmcp/elicitation.py b/fastmcp_slim/fastmcp/elicitation.py new file mode 100644 index 000000000..e38af0bce --- /dev/null +++ b/fastmcp_slim/fastmcp/elicitation.py @@ -0,0 +1,32 @@ +"""Declarative elicitation for FastMCP. + +Annotate a parameter with `Elicit(...)` to have it filled by asking the client +rather than by the model: + +```python +from typing import Annotated + +from fastmcp import FastMCP +from fastmcp.elicitation import Elicit + +mcp = FastMCP("Booking Server") + + +@mcp.tool +async def book_flight( + destination: Annotated[str, Elicit("Where would you like to fly?")], +) -> str: + return f"Booked a flight to {destination}" +``` + +The parameter is hidden from the tool's input schema, and the same function +works on both protocol eras — the framework picks the transport. + +This module is the stable import location. The implementation behind it is +expected to move into the `uncalled-for` dependency engine; importing `Elicit` +from here keeps that move invisible. +""" + +from fastmcp.server._elicit_resolution import Elicit + +__all__ = ["Elicit"] diff --git a/fastmcp_slim/fastmcp/server/_elicit_resolution.py b/fastmcp_slim/fastmcp/server/_elicit_resolution.py new file mode 100644 index 000000000..df9080d76 --- /dev/null +++ b/fastmcp_slim/fastmcp/server/_elicit_resolution.py @@ -0,0 +1,695 @@ +"""Declarative elicitation: fill a parameter by asking the client for it. + +PROVISIONAL INTERNAL MODULE — do not import from here. + +The public name is `fastmcp.elicitation.Elicit`. This module holds the engine +behind it, and the engine is scheduled to move into the `uncalled-for` +dependency package once that package can *inject* a value from `Annotated` +metadata (today its annotation path runs a dependency for its side effects and +discards the result, so a marker in an annotation cannot fill a parameter). + +When that lands, `Elicit` becomes an ordinary `uncalled_for.Dependency` +subclass, the scanning and ordering below is deleted in favour of the engine's +own DAG walk, and only the MCP-specific parts — rendering a question, recording +an answer, digesting, and choosing a transport for the protocol era — stay in +FastMCP. Nothing here is public API and none of it carries a deprecation +guarantee. + +A parameter annotated `Annotated[T, Elicit("...")]` is filled by asking the +client instead of by the model, and is hidden from the tool's input schema. How +the ask reaches the user depends on the negotiated protocol: + +- 2026-07-28 and later: there is no server-initiated back-channel (SEP-2577), so + every unanswered question is batched into one `InputRequiredResult` and the + body does not run. The client answers and re-issues the call; the parameters + resolve from those answers and the body runs. Answers from earlier rounds ride + `request_state`, which the framework seals before it reaches the wire. +- 2025-11-25 and earlier: the back-channel exists, so each question is asked + in-process with `ctx.elicit()` while the call is still open. + +The same annotated function works on both. That bridge is the point of the +declarative form: the framework can only choose a transport when the ask is not +already hard-coded into the body's control flow. + +Questions are pinned to a digest of exactly what the client was shown, so a +redeploy that rewords a question — or a retry that changes an argument feeding +one — re-asks it rather than silently reusing an answer to a different question. +""" + +from __future__ import annotations + +import base64 +import hashlib +import inspect +import json +import typing +from collections.abc import Callable, Mapping +from dataclasses import dataclass +from types import UnionType +from typing import ( + TYPE_CHECKING, + Annotated, + Any, + Generic, + Literal, + TypeVar, + get_args, + get_origin, +) + +import mcp_types +from pydantic import BaseModel, ValidationError +from uncalled_for import FailedDependency, get_dependency_parameters +from uncalled_for.resolution import resolved_dependencies + +from fastmcp.exceptions import ToolError +from fastmcp.server.elicitation import ( + ElicitConfig, + handle_elicit_accept, + parse_elicit_response_type, +) +from fastmcp.utilities.logging import get_logger + +if TYPE_CHECKING: + from fastmcp.server.context import Context + +__all__ = [ + "Elicit", + "ElicitParam", + "NeedsInput", + "find_elicit_parameters", + "resolve_elicitations", +] + +logger = get_logger(__name__) + +T = TypeVar("T") + +#: Bumped when the shape of the `request_state` payload changes. A payload from +#: another version is treated as "no progress yet" — during a rolling upgrade an +#: in-flight call re-asks rather than misreading an older layout. +_STATE_VERSION = 1 + + +class Elicit(Generic[T]): + """A request for the user to supply a value. + + Used in two positions, meaning the same thing in both. + + As parameter metadata it says the parameter is filled by asking rather than + by the model, and the annotated type is the schema for the answer — scalars, + `Literal`s, enums, dataclasses, and models all behave as they do with + `ctx.elicit()`: + + ```python + destination: Annotated[str, Elicit("Where would you like to fly?")] + ``` + + Returned from a resolver it is the question that resolver decided to ask. + A resolver returns `T | Elicit[T]`, so returning a value instead skips the + question entirely: + + ```python + def which_airport(destination: str, profile: Profile = Depends(get_profile)) -> Airport | Elicit[Airport]: + if profile.home_airport: + return profile.home_airport + return Elicit(f"Which airport in {destination}?", response_type=Airport) + + + airport: Annotated[Airport, Elicit(which_airport)] + ``` + + A parameter with a default is optional: declining or cancelling leaves the + default in place and the call proceeds. A parameter without one is required, + and declining it fails the call. + + Args: + message: The text to show the user, or a resolver that decides. A + resolver's parameters are filled by name from the call's own + arguments and from other elicited parameters, which is also what + orders the asks; it may declare its own `Depends(...)` parameters, + and it may be sync or async. + response_type: The type to ask for, exactly as `ctx.elicit()` takes it. + State it when constructing an `Elicit` inside a resolver, where the + parameter's annotation is not in view; omitted, the parameter's own + annotation is used. + title: Optional label for the wrapped `value` field, for the scalar and + shorthand forms. Same scope rules as `ctx.elicit()`. + description: Optional description for the wrapped `value` field. + """ + + def __init__( + self, + message: str | Callable[..., Any], + *, + response_type: Any = None, + title: str | None = None, + description: str | None = None, + ) -> None: + self.message = message + self.response_type = response_type + self.title = title + self.description = description + + +class NeedsInput(Exception): + """Internal: unanswered questions remain, so the body must not run. + + Raised out of parameter resolution and caught by the component that owns the + call, which turns it into the `InputRequiredResult` that is this leg's + result. Never reaches user code. + """ + + def __init__( + self, + input_requests: dict[str, Any], + request_state: str, + ) -> None: + super().__init__("elicitation input required") + self.input_requests = input_requests + self.request_state = request_state + + +@dataclass(frozen=True) +class ElicitParam: + """One parameter to be filled by asking, analyzed once at registration.""" + + name: str + marker: Elicit + response_type: Any + has_default: bool + default: Any + #: Names this parameter's question is built from — tool arguments, other + #: elicited parameters, or both. Empty for a plain string question. + depends_on: tuple[str, ...] + + async def resolve(self, values: Mapping[str, Any]) -> Any: + """Decide what this parameter needs: a value, or a question to ask. + + Returns an `Elicit` when the user has to be asked, and anything else as + the resolved value. A literal-question marker always returns itself; a + resolver decides, and may skip the ask by returning a value. + + A resolver may also declare its own `Depends(...)` parameters, which + resolve the ordinary way. + """ + if isinstance(self.marker.message, str): + return self.marker + bound = {name: values[name] for name in self.depends_on} + async with resolved_dependencies(self.marker.message, bound) as injected: + for param_name, value in injected.items(): + # The DI engine reports a dependency it could not build as a + # sentinel rather than raising, which would otherwise reach the + # resolver as a nonsense value. The common cause is a dependency + # that wants one of the call's arguments by name, which the + # engine cannot supply. + if isinstance(value, FailedDependency): + raise ToolError( + f"The resolver for {self.name!r} depends on {param_name!r}, " + "which could not be resolved" + ) from value.error + outcome = self.marker.message(**bound, **injected) + return await outcome if inspect.isawaitable(outcome) else outcome + + def type_for(self, request: Elicit[Any]) -> Any: + """The type one question asks for. + + Taken from the `Elicit` when it states one — a resolver naming + `response_type` where the parameter's annotation is out of view — and from + the parameter's own annotation otherwise. + """ + if request.response_type is not None: + return request.response_type + return self.response_type + + def config(self, request: Elicit[Any]) -> ElicitConfig: + """Schema and response handling for one question's answer.""" + return parse_elicit_response_type( + self.type_for(request), + response_title=request.title, + response_description=request.description, + ) + + +def _unwrap_optional(annotation: Any) -> Any: + """Strip a `None` arm wrapped around an `Annotated`. + + Python 3.10's `get_type_hints` still applies implicit-Optional, so a + parameter defaulting to `None` comes back as `Optional[Annotated[...]]` + rather than the `Annotated[...]` that 3.11+ reports. Both spellings mean the + same optional parameter, so both resolve to the inner annotation. + """ + if get_origin(annotation) not in (typing.Union, UnionType): + return annotation + arms = [arm for arm in get_args(annotation) if arm is not type(None)] + if len(arms) == 1 and get_origin(arms[0]) is Annotated: + return arms[0] + return annotation + + +def _elicit_marker(annotation: Any) -> Elicit | None: + """The `Elicit` marker in an `Annotated[...]`, if there is one.""" + annotation = _unwrap_optional(annotation) + if get_origin(annotation) is not Annotated: + return None + return next((m for m in get_args(annotation)[1:] if isinstance(m, Elicit)), None) + + +def _contains_elicit(annotation: Any) -> bool: + """True when an `Elicit` marker is nested somewhere inside `annotation`.""" + if get_origin(annotation) is Annotated: + return any(isinstance(m, Elicit) for m in get_args(annotation)[1:]) + return any(_contains_elicit(arg) for arg in get_args(annotation)) + + +def _response_type(annotation: Any) -> Any: + """The type to elicit, given the full `Annotated[...]` annotation. + + A `None` arm carries the optional-parameter case (`Annotated[str | None, + Elicit(...)] = None`) and is dropped: the user is asked for a `str`, and the + `None` is what a decline leaves behind. Any other metadata in the + `Annotated` is preserved so `Field(...)` constraints still shape the schema. + """ + type_arg = get_args(_unwrap_optional(annotation))[0] + if get_origin(type_arg) in (typing.Union, UnionType): + arms = [a for a in get_args(type_arg) if a is not type(None)] + if len(arms) == 1: + return arms[0] + if arms: + return typing.Union[tuple(arms)] # noqa: UP007 + return type_arg + + +def find_elicit_parameters(fn: Callable[..., Any]) -> dict[str, ElicitParam]: + """Find and validate every `Annotated[T, Elicit(...)]` parameter of `fn`. + + The returned mapping is in resolution order: a parameter whose question is + built from another elicited parameter comes after it. + + Raises: + TypeError: If a marker is buried in a union rather than applied to the + parameter directly, if a question callable asks for something that is + neither a tool argument nor another elicited parameter, or if the + questions form a cycle. + """ + try: + hints = typing.get_type_hints(fn, include_extras=True) + except (NameError, TypeError) as e: + # Annotations that cannot be resolved (a `from __future__ import + # annotations` module naming something out of scope) carry no marker we + # can see. Matching the DI engine's own tolerance, treat the function as + # having none rather than failing every tool with an odd annotation. + logger.debug("Could not read annotations of %r: %s", _fn_name(fn), e) + return {} + + signature = inspect.signature(fn) + found: dict[str, ElicitParam] = {} + for name, parameter in signature.parameters.items(): + annotation = hints.get(name) + marker = _elicit_marker(annotation) + if marker is None: + # Flag rather than silently ignore a marker that cannot take effect, + # e.g. `Annotated[str, Elicit(...)] | None`. + if annotation is not None and _contains_elicit(annotation): + raise TypeError( + f"Parameter {name!r} of {_fn_name(fn)!r} wraps Elicit(...) in a " + "union; annotate the parameter directly as " + "Annotated[T, Elicit(...)]" + ) + continue + has_default = parameter.default is not inspect.Parameter.empty + found[name] = ElicitParam( + name=name, + marker=marker, + response_type=_response_type(annotation), + has_default=has_default, + default=parameter.default if has_default else None, + depends_on=_message_parameters(marker, name, fn), + ) + + if not found: + return {} + + available = set(signature.parameters) + for spec in found.values(): + _check_declared_type(spec, _fn_name(fn)) + for dependency in spec.depends_on: + if dependency not in available: + raise TypeError( + f"The question for parameter {spec.name!r} of {_fn_name(fn)!r} " + f"asks for {dependency!r}, which is not a parameter of the " + "function; a question can only be built from the call's own " + "arguments or from other elicited parameters" + ) + return _in_resolution_order(found, _fn_name(fn)) + + +def _declared_response_type(fn: Callable[..., Any]) -> Any | None: + """The `T` a resolver declares in an `Elicit[T]` return arm, if it declares one. + + A resolver annotated `-> Airport | Elicit[Airport]` states the type it asks + for at its own definition, which is the type the parameter must accept. + Returns `None` when the resolver says nothing usable — an unannotated + resolver, or a bare `Elicit` with no parameter. + """ + try: + hints = typing.get_type_hints(fn, include_extras=True) + except (NameError, TypeError): + return None + returns = hints.get("return") + if returns is None: + return None + arms = ( + get_args(returns) + if get_origin(returns) in (typing.Union, UnionType) + else (returns,) + ) + for arm in arms: + if get_origin(arm) is Elicit: + args = get_args(arm) + return args[0] if args else None + return None + + +def _check_declared_type(spec: ElicitParam, fn_name: str) -> None: + """Reject a resolver whose declared `Elicit[T]` contradicts its parameter. + + Both are visible at registration, so a disagreement is caught at import + rather than surfacing as a validation failure on the answer. + + Raises: + TypeError: If the two types disagree. + """ + if isinstance(spec.marker.message, str): + return + declared = _declared_response_type(spec.marker.message) + if declared is None or declared == spec.response_type: + return + raise TypeError( + f"The resolver for parameter {spec.name!r} of {fn_name!r} declares it " + f"elicits {declared!r}, but the parameter is annotated " + f"{spec.response_type!r}. Make the two agree." + ) + + +def _message_parameters( + marker: Elicit, name: str, fn: Callable[..., Any] +) -> tuple[str, ...]: + """Names a resolver needs filled by name; empty for a literal question. + + A resolver's own `Depends(...)` parameters are left out: those are resolved + by the DI engine when the resolver runs, not matched against the call's + arguments. + """ + if isinstance(marker.message, str): + return () + try: + question_signature = inspect.signature(marker.message) + except (TypeError, ValueError) as e: + raise TypeError( + f"The question for parameter {name!r} of {_fn_name(fn)!r} is a callable " + "whose signature could not be read" + ) from e + injected = get_dependency_parameters(marker.message) + return tuple(p for p in question_signature.parameters if p not in injected) + + +def _in_resolution_order( + specs: dict[str, ElicitParam], fn_name: str +) -> dict[str, ElicitParam]: + """Order the parameters so each comes after the ones its question needs.""" + ordered: dict[str, ElicitParam] = {} + visiting: set[str] = set() + + def visit(name: str, trail: tuple[str, ...]) -> None: + if name in ordered: + return + if name in visiting: + cycle = " -> ".join((*trail, name)) + raise TypeError( + f"The elicited parameters of {fn_name!r} form a cycle: {cycle}" + ) + visiting.add(name) + for dependency in specs[name].depends_on: + # Only other *elicited* parameters constrain ordering; plain tool + # arguments are already available before resolution starts. + if dependency in specs: + visit(dependency, (*trail, name)) + visiting.discard(name) + ordered[name] = specs[name] + + for name in specs: + visit(name, ()) + return ordered + + +def _fn_name(fn: Callable[..., Any]) -> str: + return getattr(fn, "__name__", None) or type(fn).__name__ + + +class _Answer(BaseModel): + """One recorded answer, as it travels in `request_state`.""" + + action: Literal["accept", "decline", "cancel"] + #: The client's own content, stored exactly as it arrived so restoring it + #: revalidates the same bytes rather than a re-serialized model. + data: Any = None + #: Digest of the question this answered. + q: str + + +class _State(BaseModel): + """Everything carried from one round to the next.""" + + v: int + answers: dict[str, _Answer] = {} + #: Digest of each question asked last round, so an answer is only accepted + #: for the exact wording it was shown against. + asked: dict[str, str] = {} + + +def _decode_state(request_state: str | None) -> _State: + """Read the state a previous round carried forward. + + The string arrives already unsealed and verified by the framework, so + anything unreadable here is drift inside the operator's own fleet (a rolling + upgrade, say) and is treated as no progress rather than an error. + """ + empty = _State(v=_STATE_VERSION) + if not request_state: + return empty + try: + state = _State.model_validate(json.loads(request_state)) + except ValueError: + return empty + return state if state.v == _STATE_VERSION else empty + + +def _encode_state(answers: Mapping[str, _Answer], asked: Mapping[str, str]) -> str: + state = _State(v=_STATE_VERSION, answers=dict(answers), asked=dict(asked)) + return json.dumps(state.model_dump(mode="json"), separators=(",", ":")) + + +def _digest(request: mcp_types.ElicitRequest) -> str: + """Pin an answer to exactly what the client was shown.""" + params = request.params + rendered = json.dumps( + params.model_dump(mode="json", by_alias=True, exclude_none=True) + if params + else None, + separators=(",", ":"), + sort_keys=True, + ) + packed = hashlib.sha256(rendered.encode()).digest()[:16] + return base64.urlsafe_b64encode(packed).decode().rstrip("=") + + +def _build_request(message: str, config: ElicitConfig) -> mcp_types.ElicitRequest: + return mcp_types.ElicitRequest( + params=mcp_types.ElicitRequestFormParams( + message=message, + requested_schema=config.schema, + ) + ) + + +def _settle( + spec: ElicitParam, + action: str, + content: Any, + config: ElicitConfig, +) -> Any: + """Turn one answer into the value the parameter takes. + + Raises: + ToolError: If a required parameter's question was declined or cancelled, + or if accepted content does not match the schema it was asked for. + """ + if action == "accept": + try: + return handle_elicit_accept(config, content).data + except (ValidationError, ValueError) as e: + raise ToolError( + f"The answer for {spec.name!r} does not match the requested schema" + ) from e + if spec.has_default: + return spec.default + raise ToolError( + f"Cannot continue without {spec.name!r}: the request was {action}d. " + "Give the parameter a default to make it optional." + ) + + +async def resolve_elicitations( + specs: Mapping[str, ElicitParam], + arguments: Mapping[str, Any], + context: Context, +) -> dict[str, Any]: + """Fill every elicited parameter, asking the client for whatever is missing. + + `arguments` is the call's already-validated arguments, so a question built + from one of them sees the same value the body will. + + Raises: + NeedsInput: On the modern protocol, when questions remain unanswered. + Carries this leg's `InputRequiredResult` payload. + ToolError: If a required parameter's question was declined or cancelled. + """ + if context._is_modern_protocol(): + return await _resolve_across_rounds(specs, arguments, context) + return await _resolve_in_process(specs, arguments, context) + + +async def _resolve_in_process( + specs: Mapping[str, ElicitParam], + arguments: Mapping[str, Any], + context: Context, +) -> dict[str, Any]: + """Handshake-era path: ask over the back-channel while the call is open.""" + resolved: dict[str, Any] = {} + for spec in specs.values(): + request = await spec.resolve({**arguments, **resolved}) + if not isinstance(request, Elicit): + # The resolver already knew the answer, so nobody is asked. + resolved[spec.name] = request + continue + outcome = await context.elicit( + _message_text(request, spec), + response_type=spec.type_for(request), + response_title=request.title, + response_description=request.description, + ) + if outcome.action == "accept": + resolved[spec.name] = outcome.data + elif spec.has_default: + resolved[spec.name] = spec.default + else: + raise ToolError( + f"Cannot continue without {spec.name!r}: the request was " + f"{outcome.action}d. Give the parameter a default to make it " + "optional." + ) + return resolved + + +def _message_text(request: Elicit[Any], spec: ElicitParam) -> str: + """The text an `Elicit` shows the user. + + Raises: + ToolError: If a resolver built an `Elicit` around another callable, which + has no meaning — a resolver has already decided what to ask. + """ + if isinstance(request.message, str): + return request.message + raise ToolError( + f"The resolver for {spec.name!r} returned an Elicit wrapping a callable; " + "return Elicit() instead" + ) + + +async def _resolve_across_rounds( + specs: Mapping[str, ElicitParam], + arguments: Mapping[str, Any], + context: Context, +) -> dict[str, Any]: + """Modern-protocol path: batch what is unanswered into one result. + + Every question that can be rendered this round is visited, so independent + ones are all asked together rather than one per round trip. A question that + is built from an unanswered one cannot be rendered yet and simply waits. + """ + state = _decode_state(context.request_state) + replies = context.input_responses or {} + + resolved: dict[str, Any] = {} + pending: dict[str, Any] = {} + carry: dict[str, _Answer] = {} + asked: dict[str, str] = {} + waiting: set[str] = set() + + for spec in specs.values(): + if any(dependency in waiting for dependency in spec.depends_on): + # Its question quotes something nobody has answered yet. + waiting.add(spec.name) + continue + + decision = await spec.resolve({**arguments, **resolved}) + if not isinstance(decision, Elicit): + # The resolver already knew the answer, so nothing is asked and + # nothing is carried forward — it decides again next round. + resolved[spec.name] = decision + continue + + config = spec.config(decision) + request = _build_request(_message_text(decision, spec), config) + question = _digest(request) + + answer = _recall(state, spec.name, question) + if answer is None: + answer = _accept_reply(replies.get(spec.name), state, spec.name, question) + if answer is None: + pending[spec.name] = request + asked[spec.name] = question + waiting.add(spec.name) + continue + + carry[spec.name] = answer + resolved[spec.name] = _settle(spec, answer.action, answer.data, config) + + if pending: + raise NeedsInput(pending, _encode_state(carry, asked)) + return resolved + + +def _recall(state: _State, name: str, question: str) -> _Answer | None: + """An answer recorded on an earlier round, if it answered this same question.""" + answer = state.answers.get(name) + if answer is None: + return None + if answer.q != question: + logger.debug( + "Dropping the recorded answer for %r: the question changed since it " + "was asked", + name, + ) + return None + return answer + + +def _accept_reply( + reply: Any, state: _State, name: str, question: str +) -> _Answer | None: + """A fresh reply from the client, if it answers the question we just asked.""" + if reply is None: + return None + if state.asked.get(name) != question: + logger.info( + "Discarding the reply for %r: the question changed since it was asked", + name, + ) + return None + if not isinstance(reply, mcp_types.ElicitResult): + raise ToolError(f"The response for {name!r} is not an elicitation result") + if reply.action == "accept" and reply.content is None: + raise ToolError(f"The answer for {name!r} was accepted but carries no content") + return _Answer(action=reply.action, data=reply.content, q=question) diff --git a/fastmcp_slim/fastmcp/server/dependencies.py b/fastmcp_slim/fastmcp/server/dependencies.py index 32d8ba183..8b29bc1a7 100644 --- a/fastmcp_slim/fastmcp/server/dependencies.py +++ b/fastmcp_slim/fastmcp/server/dependencies.py @@ -34,6 +34,10 @@ from uncalled_for import Dependency, get_dependency_parameters from uncalled_for.resolution import _Depends from fastmcp.exceptions import FastMCPError +from fastmcp.server._elicit_resolution import ( + find_elicit_parameters, + resolve_elicitations, +) from fastmcp.server.auth import AccessToken from fastmcp.server.http import _current_http_request from fastmcp.utilities.async_utils import ( @@ -669,6 +673,7 @@ def without_injected_parameters( Handles: - Legacy Context injection (always works) - Depends() injection (always works - uses docket or vendored DI engine) + - ``Annotated[T, Elicit(...)]`` injection (filled by asking the client) Args: fn: Original function with Context and/or dependencies @@ -685,12 +690,15 @@ def without_injected_parameters( # Identify parameters to exclude context_kwarg = find_kwarg_by_type(fn, Context) dependency_params = get_dependency_parameters(fn) + elicit_params = find_elicit_parameters(fn) exclude = set() if context_kwarg: exclude.add(context_kwarg) if dependency_params: exclude.update(dependency_params.keys()) + if elicit_params: + exclude.update(elicit_params) if not exclude: return fn @@ -706,6 +714,15 @@ def without_injected_parameters( fn_is_async = is_coroutine_function(fn) async def wrapper(**user_kwargs: Any) -> Any: + if elicit_params: + # Questions are built from the call's already-validated arguments, so + # a question quoting one sees exactly what the body will. Raises + # NeedsInput when answers are still outstanding, which the calling + # component turns into this leg's result. + user_kwargs = { + **user_kwargs, + **await resolve_elicitations(elicit_params, user_kwargs, get_context()), + } async with resolve_dependencies(fn, user_kwargs) as resolved_kwargs: if fn_is_async: return await fn(**resolved_kwargs) diff --git a/fastmcp_slim/fastmcp/tools/function_parsing.py b/fastmcp_slim/fastmcp/tools/function_parsing.py index 72d594794..ee208fee3 100644 --- a/fastmcp_slim/fastmcp/tools/function_parsing.py +++ b/fastmcp_slim/fastmcp/tools/function_parsing.py @@ -295,12 +295,13 @@ class ParsedFunction: parameters=inner_docstring.parameters, ) - # Transform Context type annotations to Depends() for unified DI + from fastmcp.server._elicit_resolution import find_elicit_parameters from fastmcp.server.dependencies import ( transform_context_annotations, without_injected_parameters, ) + # Transform Context type annotations to Depends() for unified DI fn = transform_context_annotations(fn) # Handle injected parameters (Context, Docket dependencies) @@ -362,6 +363,19 @@ class ParsedFunction: # Save original for return_type before any schema-related replacement original_output_type = output_type + # A call carries one input-required channel, so the two ways of asking + # cannot share it: `Elicit(...)` parameters and a hand-returned + # `InputRequiredResult` would each overwrite the other's `request_state` + # and the call would never converge. Reject the combination outright + # rather than let it fail confusingly at run time. + if _contains_input_required(output_type) and find_elicit_parameters(fn): + raise TypeError( + f"Tool {fn_name!r} both declares Elicit(...) parameters and returns " + "an InputRequiredResult. A call has one channel for gathering " + "input, so ask for everything declaratively or drive the rounds " + "from the body — not both." + ) + # An `InputRequiredResult` return arm (SEP-2322 guard tools) is a # control-flow signal, not data: strip it so the residual arms drive # output-schema derivation (mirrors the SDK's func_metadata). The tool diff --git a/fastmcp_slim/fastmcp/tools/function_tool.py b/fastmcp_slim/fastmcp/tools/function_tool.py index e5787f466..60c96c387 100644 --- a/fastmcp_slim/fastmcp/tools/function_tool.py +++ b/fastmcp_slim/fastmcp/tools/function_tool.py @@ -374,6 +374,10 @@ class FunctionTool(Tool): serialized as content; the ask flows through the middleware chain as an ordinary result and the wire handler returns it to the client unmodified. """ + # Imported here, not at module scope: `fastmcp.server` pulls the provider + # stack back around to this module, so a top-level import breaks in a + # fresh interpreter. + from fastmcp.server._elicit_resolution import NeedsInput from fastmcp.server.dependencies import without_injected_parameters wrapper_fn = without_injected_parameters( @@ -386,9 +390,21 @@ class FunctionTool(Tool): exec_is_async = is_coroutine_function(wrapper_fn) strict = _strict_input_validation() - result = await self._run_body( - type_adapter, exec_is_async, arguments, strict=strict - ) + try: + result = await self._run_body( + type_adapter, exec_is_async, arguments, strict=strict + ) + except NeedsInput as needs_input: + # An `Annotated[T, Elicit(...)]` parameter has no answer yet, so this + # leg resolves to the question instead of to tool output — the same + # result a guard tool returns by hand, just assembled by the + # framework. The body has not run. + return InputRequiredToolResult( + mcp_types.InputRequiredResult( + input_requests=needs_input.input_requests, + request_state=needs_input.request_state, + ) + ) # An `InputRequiredResult` is the full result of this multi-round-trip # leg (SEP-2322), not tool-output data: wrap it in an diff --git a/tests/server/test_elicit_resolution.py b/tests/server/test_elicit_resolution.py new file mode 100644 index 000000000..3b9b84c55 --- /dev/null +++ b/tests/server/test_elicit_resolution.py @@ -0,0 +1,998 @@ +"""Declarative elicitation: `Annotated[T, Elicit(...)]` parameters. + +A parameter annotated this way is filled by asking the client rather than by +the model, and is hidden from the tool's input schema. The same annotated +function has to work on both protocol eras — batched into an +``InputRequiredResult`` on 2026-07-28 (where there is no back-channel), asked +in-process with ``ctx.elicit()`` on 2025-11-25 and earlier — because choosing +the transport is the whole reason for the declarative form. + +The engine lives in the private ``fastmcp.server._elicit_resolution`` module, +which is expected to move into ``uncalled-for``; these tests exercise it +through the public ``fastmcp.elicitation.Elicit`` surface so the move stays +invisible. +""" + +from typing import Annotated, Literal + +import mcp_types +import pytest +from pydantic import BaseModel + +from fastmcp import Client, Context, FastMCP +from fastmcp.client.elicitation import ElicitResult +from fastmcp.dependencies import Depends +from fastmcp.elicitation import Elicit +from fastmcp.exceptions import ToolError +from fastmcp.server._elicit_resolution import ( + NeedsInput, + find_elicit_parameters, + resolve_elicitations, +) +from fastmcp.server.middleware.middleware import Middleware +from fastmcp.tools.base import InputRequiredToolResult + + +class RecordAsks(Middleware): + """Records the questions asked on each leg of a call.""" + + def __init__(self) -> None: + self.rounds: list[list[str]] = [] + + @property + def asks(self) -> int: + return len(self.rounds) + + async def on_call_tool(self, context, call_next): + result = await call_next(context) + if isinstance(result, InputRequiredToolResult): + self.rounds.append(list(result.input_required.input_requests)) + return result + + +def accept(**fields): + """An elicitation handler that accepts every question with fixed fields.""" + + async def handler(message, response_type, params, ctx): + return ElicitResult(action="accept", content=response_type(**fields)) + + return handler + + +def accept_by_message(answers: dict[str, object], asked: list[str] | None = None): + """Answer each question with the value whose key appears in the message.""" + + async def handler(message, response_type, params, ctx): + if asked is not None: + asked.append(message) + for marker, value in answers.items(): + if marker in message: + return ElicitResult(action="accept", content=response_type(value=value)) + raise AssertionError(f"unexpected question: {message}") + + return handler + + +def refuse(action: Literal["decline", "cancel"] = "decline"): + async def handler(message, response_type, params, ctx): + return ElicitResult(action=action) + + return handler + + +class TestSchema: + """An elicited parameter is not something the model supplies.""" + + async def test_elicited_parameter_is_hidden(self): + mcp = FastMCP("x") + + @mcp.tool + async def book( + seats: int, + destination: Annotated[str, Elicit("Where to?")], + ) -> str: + return f"{destination} x{seats}" + + tool = await mcp.get_tool("book") + assert tool is not None + assert list(tool.parameters["properties"]) == ["seats"] + assert tool.parameters["required"] == ["seats"] + + async def test_optional_elicited_parameter_is_hidden(self): + """A default makes the ask optional, not the parameter model-supplied.""" + mcp = FastMCP("x") + + @mcp.tool + async def book( + seat: Annotated[str | None, Elicit("Window or aisle?")] = None, + ) -> str: + return seat or "none" + + tool = await mcp.get_tool("book") + assert tool is not None + assert tool.parameters.get("properties", {}) == {} + + +class TestModernProtocol: + """2026-07-28: no back-channel, so asks ride `InputRequiredResult`.""" + + async def test_single_question_completes(self): + mcp = FastMCP("x") + + @mcp.tool + async def book( + destination: Annotated[str, Elicit("Where would you like to fly?")], + ) -> str: + return f"Booked {destination}" + + async with Client( + mcp, mode="auto", elicitation_handler=accept(value="Paris") + ) as client: + assert client.protocol_version == "2026-07-28" + result = await client.call_tool("book", {}) + + assert result.data == "Booked Paris" + + async def test_body_does_not_run_until_answered(self): + """The first leg resolves to the question, not to a partial execution.""" + runs: list[str] = [] + mcp = FastMCP("x") + + @mcp.tool + async def book( + destination: Annotated[str, Elicit("Where to?")], + ) -> str: + runs.append(destination) + return destination + + async with Client( + mcp, mode="auto", elicitation_handler=accept(value="Paris") + ) as client: + await client.call_tool("book", {}) + + assert runs == ["Paris"] + + async def test_independent_questions_share_one_round(self): + """Two asks that do not depend on each other go out together. + + This is the behavioural gain over a hand-written guard, which asks in + whatever order the author wrote and pays a round trip for each. + """ + mcp = FastMCP("x") + recorder = RecordAsks() + mcp.add_middleware(recorder) + + @mcp.tool + async def book( + destination: Annotated[str, Elicit("Where to?")], + date: Annotated[str, Elicit("When?")], + ) -> str: + return f"{destination} on {date}" + + handler = accept_by_message({"Where": "Paris", "When": "2026-08-01"}) + async with Client(mcp, mode="auto", elicitation_handler=handler) as client: + result = await client.call_tool("book", {}) + + assert result.data == "Paris on 2026-08-01" + assert recorder.asks == 1 + + async def test_dependent_questions_take_a_round_each(self): + """A question that quotes an unanswered one has to wait for it.""" + mcp = FastMCP("x") + recorder = RecordAsks() + mcp.add_middleware(recorder) + + def which_airport(destination: str) -> Elicit[str]: + return Elicit(f"Which airport in {destination}?", response_type=str) + + @mcp.tool + async def book( + destination: Annotated[str, Elicit("Where to?")], + airport: Annotated[str, Elicit(which_airport)], + ) -> str: + return f"{destination}/{airport}" + + handler = accept_by_message({"Where": "Paris", "Which airport": "CDG"}) + async with Client(mcp, mode="auto", elicitation_handler=handler) as client: + result = await client.call_tool("book", {}) + + assert result.data == "Paris/CDG" + assert recorder.asks == 2 + + async def test_dependent_question_quotes_the_earlier_answer(self): + asked: list[str] = [] + mcp = FastMCP("x") + + def which_airport(destination: str) -> Elicit[str]: + return Elicit(f"Which airport in {destination}?", response_type=str) + + @mcp.tool + async def book( + destination: Annotated[str, Elicit("Where to?")], + airport: Annotated[str, Elicit(which_airport)], + ) -> str: + return f"{destination}/{airport}" + + handler = accept_by_message( + {"Where": "Paris", "Which airport": "CDG"}, asked=asked + ) + async with Client(mcp, mode="auto", elicitation_handler=handler) as client: + result = await client.call_tool("book", {}) + + assert asked == ["Where to?", "Which airport in Paris?"] + assert result.data == "Paris/CDG" + + async def test_question_built_from_a_tool_argument(self): + asked: list[str] = [] + mcp = FastMCP("x") + + def which_airport(destination: str) -> Elicit[str]: + return Elicit(f"Which airport in {destination}?", response_type=str) + + @mcp.tool + async def book( + destination: str, + airport: Annotated[str, Elicit(which_airport)], + ) -> str: + return f"{destination}/{airport}" + + handler = accept_by_message({"Which airport": "ORY"}, asked=asked) + async with Client(mcp, mode="auto", elicitation_handler=handler) as client: + result = await client.call_tool("book", {"destination": "Paris"}) + + assert asked == ["Which airport in Paris?"] + assert result.data == "Paris/ORY" + + async def test_earlier_answers_survive_later_rounds(self): + """An answer from round one is still there after round two asks again.""" + mcp = FastMCP("x") + + def follow_up(first: str) -> Elicit[str]: + return Elicit(f"After {first}, then?", response_type=str) + + @mcp.tool + async def chain( + first: Annotated[str, Elicit("First?")], + second: Annotated[str, Elicit(follow_up)], + ) -> str: + return f"{first}->{second}" + + handler = accept_by_message({"First": "a", "After a": "b"}) + async with Client(mcp, mode="auto", elicitation_handler=handler) as client: + result = await client.call_tool("chain", {}) + + assert result.data == "a->b" + + +class TestDeclining: + """A default marks the ask optional; without one, a decline stops the call.""" + + @pytest.mark.parametrize("action", ["decline", "cancel"]) + async def test_optional_falls_back_to_the_default( + self, action: Literal["decline", "cancel"] + ): + mcp = FastMCP("x") + + @mcp.tool + async def book( + seat: Annotated[str | None, Elicit("Window or aisle?")] = None, + ) -> str: + return seat or "no preference" + + async with Client( + mcp, mode="auto", elicitation_handler=refuse(action) + ) as client: + result = await client.call_tool("book", {}) + + assert result.data == "no preference" + + @pytest.mark.parametrize("action", ["decline", "cancel"]) + async def test_required_fails_the_call(self, action: Literal["decline", "cancel"]): + mcp = FastMCP("x") + + @mcp.tool + async def book( + destination: Annotated[str, Elicit("Where to?")], + ) -> str: + return destination + + async with Client( + mcp, mode="auto", elicitation_handler=refuse(action) + ) as client: + with pytest.raises(ToolError, match="Cannot continue without"): + await client.call_tool("book", {}) + + +class TestHandshakeProtocol: + """<= 2025-11-25: the back-channel exists, so asks happen in-process.""" + + async def test_same_tool_works_unchanged(self): + mcp = FastMCP("x") + + @mcp.tool + async def book( + destination: Annotated[str, Elicit("Where would you like to fly?")], + ) -> str: + return f"Booked {destination}" + + async with Client( + mcp, mode="legacy", elicitation_handler=accept(value="Paris") + ) as client: + assert client.protocol_version != "2026-07-28" + result = await client.call_tool("book", {}) + + assert result.data == "Booked Paris" + + async def test_dependent_questions_still_ordered(self): + asked: list[str] = [] + mcp = FastMCP("x") + + def which_airport(destination: str) -> Elicit[str]: + return Elicit(f"Which airport in {destination}?", response_type=str) + + @mcp.tool + async def book( + destination: Annotated[str, Elicit("Where to?")], + airport: Annotated[str, Elicit(which_airport)], + ) -> str: + return f"{destination}/{airport}" + + handler = accept_by_message( + {"Where": "Paris", "Which airport": "CDG"}, asked=asked + ) + async with Client(mcp, mode="legacy", elicitation_handler=handler) as client: + result = await client.call_tool("book", {}) + + assert asked == ["Where to?", "Which airport in Paris?"] + assert result.data == "Paris/CDG" + + async def test_optional_falls_back_to_the_default(self): + mcp = FastMCP("x") + + @mcp.tool + async def book( + seat: Annotated[str | None, Elicit("Window or aisle?")] = None, + ) -> str: + return seat or "no preference" + + async with Client(mcp, mode="legacy", elicitation_handler=refuse()) as client: + result = await client.call_tool("book", {}) + + assert result.data == "no preference" + + +class TestResponseTypes: + """The annotated type is the schema, matching `ctx.elicit()`'s ergonomics.""" + + async def test_model(self): + class Airport(BaseModel): + code: str + + mcp = FastMCP("x") + + @mcp.tool + async def book( + airport: Annotated[Airport, Elicit("Which airport?")], + ) -> str: + return airport.code + + async with Client( + mcp, mode="auto", elicitation_handler=accept(code="CDG") + ) as client: + result = await client.call_tool("book", {}) + + assert result.data == "CDG" + + async def test_scalar_int(self): + mcp = FastMCP("x") + + @mcp.tool + async def book(seats: Annotated[int, Elicit("How many seats?")]) -> int: + return seats * 2 + + async with Client( + mcp, mode="auto", elicitation_handler=accept(value=3) + ) as client: + result = await client.call_tool("book", {}) + + assert result.data == 6 + + +class TestInterop: + """Elicited parameters sit alongside the other injected kinds.""" + + async def test_with_context_and_depends(self): + mcp = FastMCP("x") + + def house_style() -> str: + return "!" + + @mcp.tool + async def book( + ctx: Context, + destination: Annotated[str, Elicit("Where to?")], + style: str = Depends(house_style), + ) -> str: + return f"{ctx.fastmcp.name}:{destination}{style}" + + async with Client( + mcp, mode="auto", elicitation_handler=accept(value="Paris") + ) as client: + result = await client.call_tool("book", {}) + + assert result.data == "x:Paris!" + + +class Airport(BaseModel): + code: str + + +class TestConditionalResolvers: + """A resolver returns `T | Elicit[T]` — a value means nobody is asked.""" + + async def test_returning_a_value_asks_nothing(self): + mcp = FastMCP("x") + recorder = RecordAsks() + mcp.add_middleware(recorder) + + def which_airport(destination: str) -> str | Elicit[str]: + if destination == "London": + return "LHR" # only one option — no question + return Elicit(f"Which airport in {destination}?", response_type=str) + + @mcp.tool + async def book( + destination: str, + airport: Annotated[str, Elicit(which_airport)], + ) -> str: + return f"{destination}/{airport}" + + async def never(message, response_type, params, ctx): + raise AssertionError(f"should not have asked: {message}") + + async with Client(mcp, mode="auto", elicitation_handler=never) as client: + result = await client.call_tool("book", {"destination": "London"}) + + assert result.data == "London/LHR" + assert recorder.asks == 0 + + async def test_the_same_resolver_still_asks_when_it_must(self): + mcp = FastMCP("x") + + def which_airport(destination: str) -> str | Elicit[str]: + if destination == "London": + return "LHR" + return Elicit(f"Which airport in {destination}?", response_type=str) + + @mcp.tool + async def book( + destination: str, + airport: Annotated[str, Elicit(which_airport)], + ) -> str: + return f"{destination}/{airport}" + + handler = accept_by_message({"Which airport": "CDG"}) + async with Client(mcp, mode="auto", elicitation_handler=handler) as client: + result = await client.call_tool("book", {"destination": "Paris"}) + + assert result.data == "Paris/CDG" + + async def test_resolver_beats_a_stale_answer_on_a_later_round(self): + """The resolver re-runs every round, so a value it computes on round two + wins over whatever the client echoed back.""" + mcp = FastMCP("x") + known: list[str] = [] + + def which_airport(destination: str) -> str | Elicit[str]: + if known: + return known[0] # learned between rounds + return Elicit(f"Which airport in {destination}?", response_type=str) + + @mcp.tool + async def book( + destination: str, + date: Annotated[str, Elicit("When?")], + airport: Annotated[str, Elicit(which_airport)], + ) -> str: + return f"{destination}/{airport}/{date}" + + async def handler(message, response_type, params, ctx): + if "Which airport" in message: + known.append("LHR") # the profile gains one mid-conversation + return ElicitResult(action="accept", content=response_type(value="CDG")) + return ElicitResult( + action="accept", content=response_type(value="2026-08-01") + ) + + async with Client(mcp, mode="auto", elicitation_handler=handler) as client: + result = await client.call_tool("book", {"destination": "Paris"}) + + # The client answered "CDG", but by the next round the resolver knew "LHR". + assert result.data == "Paris/LHR/2026-08-01" + + async def test_explicit_response_type_wins_over_the_annotation(self): + mcp = FastMCP("x") + + def pick(destination: str) -> Airport | Elicit[Airport]: + return Elicit(f"Which airport in {destination}?", response_type=Airport) + + @mcp.tool + async def book( + destination: str, + airport: Annotated[Airport, Elicit(pick)], + ) -> str: + return airport.code + + async with Client( + mcp, mode="auto", elicitation_handler=accept(code="CDG") + ) as client: + result = await client.call_tool("book", {"destination": "Paris"}) + + assert result.data == "CDG" + + def test_declared_type_must_match_the_parameter(self): + """Both types are visible at registration, so a disagreement is caught + at import rather than as a validation failure on the answer.""" + mcp = FastMCP("x") + + def pick(destination: str) -> Airport | Elicit[Airport]: + return Elicit("Which airport?", response_type=Airport) + + with pytest.raises(TypeError, match="declares it elicits"): + + @mcp.tool + async def book( + destination: str, + airport: Annotated[str, Elicit(pick)], + ) -> str: + return airport + + +def accepted(**values) -> mcp_types.InputResponses: + """The `input_responses` map for one leg: an accepted answer per key.""" + return { + key: mcp_types.ElicitResult(action="accept", content={"value": value}) + for key, value in values.items() + } + + +def questions(result) -> dict[str, str]: + """The message shown for each key on one leg of a call.""" + leg = result.input_required + assert leg is not None, "expected an ask, got a terminal result" + assert leg.input_requests is not None + asked: dict[str, str] = {} + for key, request in leg.input_requests.items(): + assert isinstance(request, mcp_types.ElicitRequest) + assert request.params is not None + asked[key] = request.params.message + return asked + + +def carried(result) -> str | None: + """The opaque state to hand back on the next leg.""" + assert result.input_required is not None + return result.input_required.request_state + + +class TestDrivingLegsByHand: + """`` hands back each leg instead of resolving it, + so a test can assert on the wire shape a client would actually receive.""" + + async def test_each_leg_is_visible(self): + mcp = FastMCP("x") + + def which_airport(destination: str) -> Elicit[str]: + return Elicit(f"Which airport in {destination}?", response_type=str) + + @mcp.tool + async def book( + destination: Annotated[str, Elicit("Where would you like to fly?")], + airport: Annotated[str, Elicit(which_airport)], + ) -> str: + return f"Booked {destination}/{airport}" + + # No elicitation_handler — nothing drives the exchange but this test. + async with Client(mcp) as client: + first = await client.call_tool("book") + assert questions(first) == {"destination": "Where would you like to fly?"} + + second = await client.call_tool( + "book", + input_responses=accepted(destination="Paris"), + request_state=carried(first), + ) + # Only the airport — the destination is not asked again. + assert questions(second) == {"airport": "Which airport in Paris?"} + + final = await client.call_tool( + "book", + input_responses=accepted(airport="CDG"), + request_state=carried(second), + ) + + assert final.input_required is None + assert final.data == "Booked Paris/CDG" + + async def test_independent_questions_arrive_in_one_leg(self): + mcp = FastMCP("x") + + @mcp.tool + async def book( + destination: Annotated[str, Elicit("Where to?")], + date: Annotated[str, Elicit("When?")], + ) -> str: + return f"{destination} on {date}" + + async with Client(mcp) as client: + first = await client.call_tool("book") + assert questions(first) == { + "destination": "Where to?", + "date": "When?", + } + + final = await client.call_tool( + "book", + input_responses=accepted(destination="Paris", date="2026-08-01"), + request_state=carried(first), + ) + + assert final.data == "Paris on 2026-08-01" + + async def test_a_resolver_that_knows_asks_nothing(self): + mcp = FastMCP("x") + + def which_airport(destination: str) -> str | Elicit[str]: + return ( + "LHR" + if destination == "London" + else Elicit("Which?", response_type=str) + ) + + @mcp.tool + async def book( + destination: str, + airport: Annotated[str, Elicit(which_airport)], + ) -> str: + return f"{destination}/{airport}" + + async with Client(mcp) as client: + result = await client.call_tool("book", {"destination": "London"}) + + # Terminal on the first leg — there was never anything to ask. + assert result.input_required is None + assert result.data == "London/LHR" + + +class TestAskedOnce: + """An answer already given satisfies its question on later rounds.""" + + async def test_each_question_reaches_the_user_once(self): + """Resolvers re-run every round, so without recall a three-round call + would put the first question six times.""" + mcp = FastMCP("x") + + def second(a: str) -> Elicit[str]: + return Elicit(f"second, given {a}?", response_type=str) + + def third(b: str) -> Elicit[str]: + return Elicit(f"third, given {b}?", response_type=str) + + @mcp.tool + async def chain( + a: Annotated[str, Elicit("first?")], + b: Annotated[str, Elicit(second)], + c: Annotated[str, Elicit(third)], + ) -> str: + return f"{a}{b}{c}" + + asked: list[str] = [] + + async def handler(message, response_type, params, ctx): + asked.append(message) + return ElicitResult( + action="accept", content=response_type(value=str(len(asked))) + ) + + async with Client(mcp, mode="auto", elicitation_handler=handler) as client: + result = await client.call_tool("chain", {}) + + assert result.data == "123" + assert asked == ["first?", "second, given 1?", "third, given 2?"] + + +class TestOrdering: + """Where and when a question is asked both fall out of the annotations.""" + + async def test_independent_questions_keep_signature_order(self): + """Signature order is the lever for presentation order — there is no other.""" + mcp = FastMCP("x") + recorder = RecordAsks() + mcp.add_middleware(recorder) + + @mcp.tool + async def book( + destination: Annotated[str, Elicit("Where?")], + date: Annotated[str, Elicit("When?")], + seat: Annotated[str, Elicit("Window or aisle?")], + ) -> str: + return f"{destination}/{date}/{seat}" + + handler = accept_by_message( + {"Where": "Paris", "When": "2026-08-01", "Window": "window"} + ) + async with Client(mcp, mode="auto", elicitation_handler=handler) as client: + await client.call_tool("book", {}) + + assert recorder.rounds == [["destination", "date", "seat"]] + + async def test_confirmation_quoting_details_waits_for_them(self): + """A confirmation that names what it confirms is ordered by saying so, + rather than by a parameter added to hold it back.""" + mcp = FastMCP("x") + recorder = RecordAsks() + mcp.add_middleware(recorder) + + def confirm(destination: str, date: str) -> Elicit[bool]: + return Elicit( + f"Book a flight to {destination} on {date}?", response_type=bool + ) + + @mcp.tool + async def book( + destination: Annotated[str, Elicit("Where?")], + date: Annotated[str, Elicit("When?")], + proceed: Annotated[bool, Elicit(confirm)], + ) -> str: + return f"Booked {destination}" if proceed else "Cancelled" + + asked: list[str] = [] + + async def handler(message, response_type, params, ctx): + asked.append(message) + if "Where" in message: + return ElicitResult( + action="accept", content=response_type(value="Paris") + ) + if "When" in message: + return ElicitResult( + action="accept", content=response_type(value="2026-08-01") + ) + return ElicitResult(action="accept", content=response_type(value=True)) + + async with Client(mcp, mode="auto", elicitation_handler=handler) as client: + result = await client.call_tool("book", {}) + + assert recorder.rounds == [["destination", "date"], ["proceed"]] + assert asked[-1] == "Book a flight to Paris on 2026-08-01?" + assert result.data == "Booked Paris" + + +class TestQuestionDependencies: + """A question is an ordinary function: it can declare its own dependencies.""" + + async def test_question_resolves_its_own_depends(self): + mcp = FastMCP("x") + + def house_prefix() -> str: + return "[ACME]" + + def styled(prefix: str = Depends(house_prefix)) -> Elicit[str]: + return Elicit(f"{prefix} Window or aisle?") + + @mcp.tool + async def seat(choice: Annotated[str, Elicit(styled)]) -> str: + return choice + + asked: list[str] = [] + handler = accept_by_message({"Window or aisle": "window"}, asked=asked) + async with Client(mcp, mode="auto", elicitation_handler=handler) as client: + result = await client.call_tool("seat", {}) + + assert asked == ["[ACME] Window or aisle?"] + assert result.data == "window" + + async def test_async_question(self): + mcp = FastMCP("x") + + async def ask_later(destination: str) -> Elicit[str]: + return Elicit(f"Which airport in {destination}?") + + @mcp.tool + async def book( + destination: str, + airport: Annotated[str, Elicit(ask_later)], + ) -> str: + return airport + + handler = accept_by_message({"Which airport": "CDG"}) + async with Client(mcp, mode="auto", elicitation_handler=handler) as client: + result = await client.call_tool("book", {"destination": "Paris"}) + + assert result.data == "CDG" + + async def test_unresolvable_question_dependency_names_itself(self): + """The DI engine reports a failed dependency rather than raising, so the + sentinel has to be caught before it reaches the question as a value.""" + mcp = FastMCP("x") + + def needs_a_tool_argument(destination: str) -> str: + return destination + + def styled(place: str = Depends(needs_a_tool_argument)) -> Elicit[str]: + return Elicit(f"Where in {place}?") + + @mcp.tool + async def book( + destination: str, + airport: Annotated[str, Elicit(styled)], + ) -> str: + return airport + + async with Client( + mcp, mode="auto", elicitation_handler=accept(value="CDG") + ) as client: + with pytest.raises(ToolError, match="depends on 'place'"): + await client.call_tool("book", {"destination": "Paris"}) + + +class _StubContext: + """The three things resolution reads off a live context.""" + + def __init__( + self, + *, + modern: bool = True, + request_state: str | None = None, + input_responses: dict | None = None, + ) -> None: + self.request_state = request_state + self.input_responses = input_responses + self._modern = modern + + def _is_modern_protocol(self) -> bool: + return self._modern + + +async def _ask_once(specs, arguments, context): + """Run one round, returning either the values or the raised question.""" + try: + return await resolve_elicitations(specs, arguments, context), None + except NeedsInput as needs_input: + return None, needs_input + + +class TestQuestionDigest: + """An answer only counts for the exact question it was shown against.""" + + def _specs(self, question): + def book(seat: Annotated[str, Elicit(question)]) -> str: + return seat + + return find_elicit_parameters(book) + + async def test_answer_to_a_changed_question_is_re_asked(self): + """A redeploy that rewords a question must not reuse the old answer.""" + first = self._specs("Window or aisle?") + _, asked = await _ask_once(first, {}, _StubContext()) + assert asked is not None + + reply = { + "seat": mcp_types.ElicitResult(action="accept", content={"value": "W"}) + } + + # Same wording: the reply is accepted. + same = await _ask_once( + first, + {}, + _StubContext(request_state=asked.request_state, input_responses=reply), + ) + assert same[0] == {"seat": "W"} + + # Reworded: the reply is dropped and the new question goes out instead. + reworded = self._specs("Which seat would you prefer?") + values, again = await _ask_once( + reworded, + {}, + _StubContext(request_state=asked.request_state, input_responses=reply), + ) + assert values is None + assert again is not None + assert "seat" in again.input_requests + + async def test_unreadable_state_is_treated_as_no_progress(self): + """Drift inside a fleet re-asks rather than misreading an older layout.""" + specs = self._specs("Window or aisle?") + values, asked = await _ask_once( + specs, {}, _StubContext(request_state='{"v":999,"answers":{}}') + ) + assert values is None + assert asked is not None + + +class TestRegistrationErrors: + """Signature mistakes fail at registration, not on the first call.""" + + def test_question_asks_for_an_unknown_name(self): + mcp = FastMCP("x") + + def question(nonexistent: str) -> Elicit[str]: + return Elicit(nonexistent) + + with pytest.raises(TypeError, match="not a parameter of the function"): + + @mcp.tool + async def book( + airport: Annotated[str, Elicit(question)], + ) -> str: + return airport + + def test_cyclic_questions(self): + mcp = FastMCP("x") + + def needs_b(b: str) -> Elicit[str]: + return Elicit(b) + + def needs_a(a: str) -> Elicit[str]: + return Elicit(a) + + with pytest.raises(TypeError, match="form a cycle"): + + @mcp.tool + async def book( + a: Annotated[str, Elicit(needs_b)], + b: Annotated[str, Elicit(needs_a)], + ) -> str: + return a + b + + def test_mixing_with_a_hand_returned_ask(self): + """One call, one input channel — the two ways of asking cannot share it.""" + import mcp_types + + mcp = FastMCP("x") + + with pytest.raises(TypeError, match="one channel for gathering input"): + + @mcp.tool + async def book( + destination: Annotated[str, Elicit("Where to?")], + ) -> str | mcp_types.InputRequiredResult: + return destination + + def test_marker_buried_out_of_reach(self): + """A marker somewhere the framework cannot honour it fails loudly.""" + mcp = FastMCP("x") + + with pytest.raises(TypeError, match="wraps Elicit"): + + @mcp.tool + async def book( + destinations: list[Annotated[str, Elicit("Where?")]], + ) -> str: + return ",".join(destinations) + + @pytest.mark.parametrize( + "annotation", + [ + Annotated[str | None, Elicit("Window or aisle?")], + Annotated[str, Elicit("Window or aisle?")] | None, + ], + ids=["none-inside", "none-outside"], + ) + async def test_optional_spellings_are_equivalent(self, annotation): + """Python 3.10 applies implicit-Optional to a `= None` parameter, so the + two spellings are indistinguishable there and must behave alike.""" + mcp = FastMCP("x") + + @mcp.tool + async def book(seat: annotation = None) -> str: + return seat or "no preference" + + tool = await mcp.get_tool("book") + assert tool is not None + assert tool.parameters.get("properties", {}) == {} + + async with Client(mcp, mode="auto", elicitation_handler=refuse()) as client: + result = await client.call_tool("book", {}) + + assert result.data == "no preference"