ctx.sample(), ctx.elicit(), ctx.listRoots(). The modern era has no server-to-client channel, so a handler cannot push a request back. It asks by returning instead.
A handler returns inputRequired(...) with the requests the client must fulfil. The client fulfils them and retries the same call, this time carrying the answers. The handler runs again, reads the answers, and finishes. This back-and-forth is a multi-round-trip request (MRTR). The key property is write-once: a handler written with inputRequired(...) serves both eras unchanged.
Returning inputRequired
inputRequired, acceptedContent, and inputResponse are exported from @prefecthq/fastmcp-ts/server. To ask for input, return inputRequired({ inputRequests }). Each entry in inputRequests is one embedded request, built with a per-kind helper: inputRequired.elicit(...) for a form, inputRequired.createMessage(...) for LLM sampling, and inputRequired.listRoots() for the client’s roots.
A handler that asks for confirmation returns early with an elicitation request, then completes on the retry once the answer arrives.
server.ts
Reading the response
On the retry, the client’s answers arrive onctx.inputResponses, keyed by the same names you used in inputRequests. Read them through acceptedContent(ctx.inputResponses, key) rather than by indexing the object. The reader validates the response shape and returns the accepted content, or undefined when the entry is missing, declined, or cancelled. For a non-elicitation answer — a sampling result or a roots list — use inputResponse(ctx.inputResponses, key), which returns the raw response view.
ctx.inputResponses is undefined on the flow’s first call, because there is no prior round to answer. That is why the handler above tests accepted?.confirm: an undefined or unconfirmed answer means “ask”, and an accepted answer means “proceed”. The same branch drives both rounds.
The values arrive from the client and are untrusted. acceptedContent checks the response shape, not your business rules — validate the content against your own schema before acting on it.
Carrying state across rounds
The confirmation handler above needs no carried state: the retry re-sends the original tool arguments, socount is present on both rounds. A longer flow — several questions, or a value computed in an early round — needs to carry state the client cannot forge. That is requestState.
Mint state with ctx.mintRequestState(payload) and return it alongside the requests. On the retry, read it back with ctx.requestState<T>(). The state round-trips through the client as an opaque string.
server.ts
requestState comes back as attacker-controlled input, so integrity matters. Configure FastMCPOptions.requestState with an HMAC key, and every minted state is signed and verified before your handler sees it. A tampered or expired state is rejected with -32602 before the handler runs. Without that option, mintRequestState falls back to an unsigned string and warns — never let unsigned state influence authorization, resource access, or business logic. That option is not only a safety measure, either: without it, ctx.requestState() on the next round hands back the raw string you minted, unparsed, not the object you passed in — only a configured verify step decodes it, so a flow that reads structured state back needs FastMCPOptions.requestState configured to work at all. State and handles covers requestState in full.
server.ts
The requestState-only pattern
This example will not run withoutFastMCPOptions.requestState configured with a key — and neither will the combined example above it. Without a key, ctx.requestState() hands the handler back the raw string mintRequestState minted, not the object you passed in, so state?.index and state?.total below are always undefined and the handler never advances. On a legacy connection — sessionful or stateless — that surfaces as the call failing once inputRequired.maxRounds rounds are used up (about 2 seconds at the default 250ms pacing), with the error “still required input after 8 rounds”. See the requestState snippet above for the minimum config: a FastMCP constructed with requestState: { key: /* at least 32 bytes */ }.
The example above pairs requestState with an elicitation, so the client still has something to fulfil each round. Some flows have nothing to ask — the handler just needs another turn to keep working. Drop inputRequests entirely and return requestState alone, and this is also the one multi-round-trip pattern that survives on a stateless legacy HTTP server, for reasons covered in one handler, both eras below.
A handler that sums a large array a few items at a time can use it to carry its running position and total between rounds. This needs FastMCPOptions.requestState configured, per the note above:
server.ts
input_required with nothing to answer, and resends the same call carrying requestState. On a legacy connection — sessionful or stateless — nothing is retried; the next section covers what happens instead.
One handler, both eras
The handlers above are modern-era code by shape, yet they serve legacy clients unchanged. On a legacy connection the SDK’s legacy shim turns aninputRequired(...) return into a real server-to-client request over the live session, collects the answer, and re-enters the handler with ctx.inputResponses populated. You write the flow once; the shim bridges it for 2025-era clients.
This is why inputRequired(...) is the recommended way to ask for input in new code. The older ctx.sample(), ctx.elicit(), and ctx.listRoots() calls still work on a sessionful legacy connection, which is the default, but they throw on a modern request, or on a legacy HTTP server running in stateless mode — the error names inputRequired(...) as the replacement either way, and on a stateless server it points at the requestState-only form specifically. Reaching for the return-value form from the start means one code path for both eras.
That replacement is not a full escape from the stateless limit, though. An inputRequests entry, such as inputRequired.elicit(...), still needs the legacy shim to turn it into a real server-to-client request over a live session. That is the same session ctx.elicit() needs, so it fails the same way on a stateless server.
A bare requestState, with no inputRequests, is the one form that survives — but not because the client resends anything. On a legacy connection, sessionful or stateless, the shim recognizes there is nothing to push, sleeps 250ms (REQUEST_STATE_ONLY_LEG_PACING_MS), and re-enters your handler itself, in the same process, inside the single HTTP request the client already sent. It repeats that — sleep, re-enter, check the result — until your handler stops returning inputRequired(...), or until it has looped maxRounds times (default 8), at which point it fails the call rather than loop forever. The client sees one request and one final response; on this path it never receives an intermediate input_required result and never retries anything itself. A modern client is different: there, requestState alone really does end the round in the client’s hands, and it really does resend the call — see the requestState-only pattern above for both sides of that split.
Two things follow from a loop the client cannot see or pace. Each round holds the original HTTP request open at least 250ms longer, so a flow that needs several rounds can hold a request open for whole seconds — worth weighing against the request-time budget that stateless mode exists to protect. And the loop paces itself on a fixed clock; it cannot wait on anything else. A handler that expects to pause between rounds for an external event — a human approval, a webhook — has no way to signal that to the shim, and will instead spin through its rounds on the 250ms cadence until maxRounds runs out and the call fails. This form depends on the same legacy shim the inputRequests form uses; serving knobs below covers legacyShim: false, which turns off both alike. The client side of fulfilment is covered in handlers and sampling.
Serving knobs
FastMCPOptions.inputRequired tunes the legacy shim. maxRounds caps how many times the shim re-enters a handler before it fails — the default is 8, and it is the only bound on a requestState-only flow, whose 250ms pacing between rounds is fixed and not configurable. roundTimeoutMs sets the per-leg timeout for the shim’s embedded requests, defaulting to 10 minutes to allow for human-paced answers — it does not apply to the requestState-only pacing, which has no embedded request to time out. Set legacyShim: false to disable the bridge entirely, so an inputRequired(...) return on a legacy request fails loudly instead, whether or not it carries inputRequests. These knobs affect the legacy era only; a modern client fulfils the requests itself.