MCP elicitation no longer holds a stream
August 28, 2026
MCP elicitation on a Worker is a return now. You send input_required, the request ends, and Claude Code draws the form after the isolate has already gone home.
The page a lot of people still land on is Cloudflare's older remote MCP guide, the one that still lists McpAgent as the elicitation option. That pathPATHThe shell environment variable listing directories searched in order when a command name is typed, so every lookup walks each entry until one matches.See also WSLENV, appendWindowsPath, command lookup awaits elicitInput on a live session. Spec 2026-07-28 replaced the held stream with Multi Round-Trip Requests, and Cloudflare's MCP handler API says the Worker does not remain suspended while a user responds.
Copy the await and you pay for a pinned isolate, or you time out while the human is still reading the prompt.
What has to be true first#
This is a Worker you can paste, not a recap of why MCP went stateless. The session-moved explainer already covers that. Here the isolate has to finish before the form is up.
- Wrangler 4.x with
nodejs_compatand acompatibility_dateat or after 2026-06-11 agentsplus@modelcontextprotocol/server@2.0.0andzod, the pin from Cloudflare's handler API- A signing secret of at least 32 random bytes in
.dev.varsasMRTR_REQUEST_STATE_KEY - Claude Code 2.1.232 or later so the v2 runtime can speak protocol 2026-07-28 on HTTP
- An HTTP URL that ends in
/mcp
The elicitation dialog itself shipped in 2.1.76. The retry that matches this Worker is the later runtime. If claude --version is older than 2.1.232, stop and update before blaming the handler.
No Durable ObjectDurable ObjectA Cloudflare Workers primitive that gives a single instance of code its own persistent storage and guaranteed single-threaded execution, used to hold state a stateless server can't.See also McpAgent, state handle is required for this demo. Application state still wants a store. The protocol session is not that store.
Return input_required from the Worker#

Think of the first tools/call as a numbered ticket left on the counter. The shop is closed. The human fills a form later, then walks in with the ticket. That ticket is requestState. Camping at the register is the 2025 stream, and it is the frozen path.
Cloudflare's current example is mcp-elicitation-mrtr. The tool is increase-counter. Two input rounds, three Worker requests, zero pending Promises.
1. Pin the stateless handler#
McpAgent still compiles. It is also deprecated and feature-frozen. createLegacyMcpHandler is the temporary bridge for people who still need a session transport. New elicitation goes through createMcpHandler from agents/mcp/server and a factory that returns a fresh McpServer from @modelcontextprotocol/server.
Pass the factory. A global server instance is the bug this API was written to stop.
npm i agents @modelcontextprotocol/server@2.0.0 zodlegacy: "reject" makes the endpoint stateless-only. The default legacy: "stateless" still accepts ordinary tools from older clients, and it still fails pushed elicitation/create immediately. GET and DELETE already return 405. If you wanted the old stream, you picked the wrong handler.
CheckpointcheckpointA saved snapshot of a model's weights at a specific point in training or fine-tuning, the exact file that gets tested, released, or further modified.See also abliteration, frontier model. The Worker boots. /mcp is the only path. A leftover Mcp-Session-Id header is ignored.
2. Put a signing key in .dev.vars#
requestState round-trips through the client. Spec says treat it as attacker-controlled if it influences anything that matters, and protect it with HMAC or AEAD. The TypeScript SDK's createRequestStateCodec is HMAC-SHA256. Signed, not encrypted. The client can base64url-decode the payload, so keep secrets out of it.
printf 'MRTR_REQUEST_STATE_KEY=%s\n' "$(openssl rand -base64 32)" > .dev.varsProduction is wrangler secret put MRTR_REQUEST_STATE_KEY. Do not reuse the local value. The official README says at least 32 bytes, and it means it.
bind in this demo ties the blob to mcpReq.method, so a token minted for tools/call cannot hop onto a different method. That is the floor, not production. Spec says if requestState influences auth or business logic you protect integrity, and you should bind the authenticated principal, a short TTLTTLThe time a cached prompt stays valid before it expires and must be rewritten at full cost. Shortening it turns yesterday's cheap cache reads into today's expensive cache writes.See also cache read, cache write, and an identifier for the originating request. The TypeScript codec takes ttlSeconds. This counter demo uses the method bind plus HMAC because there is no user. A refund tool that only bound the method would still replay inside another tools/call.
Checkpoint. wrangler dev starts. Drop the secret and the process throws MRTR_REQUEST_STATE_KEY must be configured instead of serving a handler that cannot seal anything.
3. Return input_required on the first call#
The tools spec lets tools/call answer with an InputRequiredResult. resultType is input_required. Inside inputRequests sits an elicitation/create with mode: "form" and a flat JSON Schema. Nested objects are out. Passwords are out. A number and a boolean are in.
The handler returns. It does not await the human.
That return is the whole trick. The JSON-RPC response goes out. The Worker request is over. Claude Code still has a form to draw. Those two facts used to be glued together by an open stream. They are not glued now.
Checkpoint. Call increase-counter with { "current": 10 }. The result's resultType is input_required. wrangler dev has already logged the POST as finished.
4. Resume from sealed requestState#
The next POST is a new tools/call with a new JSON-RPC id, the original arguments, that round's inputResponses, and the echoed requestState. Spec is blunt about it. The two requests are independent.
Here is the gotcha that eats people who treat this like a conversation that remembers. inputResponses do not accumulate. Round two has the amount, not a pile of earlier answers. Round three has the confirm checkbox. The current value and the accepted amount have to already live in the sealed blob, or the confirm step is confirming a ghost.
acceptedContent takes a Zod schema because the client is untrusted. inputResponse is how you tell a decline from a first visit. A cancel returns Counter increase cancelled. rather than pretending the human said no to a prompt they never saw.
Paste this as src/index.ts. It is the official example with the same two-round tool, same codec, same /mcp route.
import {
McpServer,
acceptedContent,
createRequestStateCodec,
inputRequired,
inputResponse,
type CallToolResult,
type InputRequiredResult,
type RequestStateCodec
} from "@modelcontextprotocol/server";
import { createMcpHandler } from "agents/mcp/server";
import { z } from "zod";
const amountSchema = z.object({ amount: z.number() });
const confirmationSchema = z.object({ confirm: z.boolean() });
type CounterRequestState =
| { step: "amount"; current: number }
| { step: "confirmation"; current: number; amount: number };
type Env = { MRTR_REQUEST_STATE_KEY: string };
function createServer(
requestStateCodec: RequestStateCodec<CounterRequestState>
) {
const server = new McpServer(
{
name: "stateless-mrtr-elicitation-demo",
version: "1.0.0"
},
{ requestState: { verify: requestStateCodec.verify } }
);
server.registerTool(
"increase-counter",
{
description:
"Calculate a counter increase using two stateless elicitation rounds",
inputSchema: z.object({
current: z.number().describe("Current counter value")
})
},
async (
{ current },
context
): Promise<CallToolResult | InputRequiredResult> => {
const state = context.mcpReq.requestState<CounterRequestState>();
if (!state) {
return inputRequired({
inputRequests: {
amount: inputRequired.elicit({
message: "By how much should the counter increase?",
requestedSchema: {
type: "object",
properties: {
amount: {
type: "number",
title: "Amount",
description: "The amount to add to the current value"
}
},
required: ["amount"]
}
})
},
requestState: await requestStateCodec.mint(
{ step: "amount", current },
context
)
});
}
if (state.step === "amount") {
const amountResponse = inputResponse(
context.mcpReq.inputResponses,
"amount"
);
if (
amountResponse.kind === "elicit" &&
amountResponse.action !== "accept"
) {
return cancelled();
}
const amount = acceptedContent(
context.mcpReq.inputResponses,
"amount",
amountSchema
);
if (!amount) return cancelled();
return inputRequired({
inputRequests: {
confirmation: inputRequired.elicit({
message: `Increase ${state.current} by ${amount.amount}?`,
requestedSchema: {
type: "object",
properties: {
confirm: {
type: "boolean",
title: "Confirm increase"
}
},
required: ["confirm"]
}
})
},
requestState: await requestStateCodec.mint(
{
step: "confirmation",
current: state.current,
amount: amount.amount
},
context
)
});
}
const confirmationResponse = inputResponse(
context.mcpReq.inputResponses,
"confirmation"
);
if (
confirmationResponse.kind === "elicit" &&
confirmationResponse.action !== "accept"
) {
return cancelled();
}
const confirmation = acceptedContent(
context.mcpReq.inputResponses,
"confirmation",
confirmationSchema
);
if (!confirmation?.confirm) return cancelled();
const next = state.current + state.amount;
return {
content: [
{
type: "text",
text: `Counter increased by ${state.amount}; next value is ${next}`
}
]
};
}
);
return server;
}
function cancelled(): CallToolResult {
return {
content: [{ type: "text", text: "Counter increase cancelled." }]
};
}
export default {
fetch(request, env, ctx) {
if (!env.MRTR_REQUEST_STATE_KEY) {
throw new Error("MRTR_REQUEST_STATE_KEY must be configured");
}
const requestStateCodec = createRequestStateCodec<CounterRequestState>({
key: env.MRTR_REQUEST_STATE_KEY,
bind: ({ mcpReq }) => mcpReq.method
});
return createMcpHandler(() => createServer(requestStateCodec), {
route: "/mcp",
legacy: "reject"
})(request, env, ctx);
}
} satisfies ExportedHandler<Env>;Checkpoint. Second POST asks for confirm and already knows current from the blob. Third POST prints Counter increased by 5; next value is 15 if the human typed 5. If you skipped the mint and expected round three to still hold the amount in inputResponses, you get cancel, and you deserved it.
Point Claude Code at the Worker#

Claude Code's MCP docs say elicitation dialogs appear automatically. No extra config on the client. Form mode is a dialog with the fields from requestedSchema. URL mode opens a browser, then you confirm in the CLI. This tutorial stays on form mode.
5. Add the HTTP server#
claude mcp add --transport http counter http://localhost:8787/mcpJSON configs that copy a url from someone else's client still need "type": "http". A url with no type is treated as stdioSTDIO transportA way of connecting an MCP client and server over standard input and output streams rather than HTTP, where the spec requires clients to re-send subscriptions after a server crash.See also resource subscription, subscriptions/listen and skipped. Claude Code's MCP docs say the skip reports that the server has a url but no type. Before 2.1.202 it looked like command: expected string, received undefined. Run claude mcp get counter either way.
On 2.1.232 or later, Claude Code uses the v2 runtime, which is MCP TypeScript SDK 2.0, and it asks HTTP servers whether they speak 2026-07-28. That is the retry path this Worker returns. Older CLIs can draw a form and still speak the handshakeinitialize handshakeThe one-time exchange at the start of an MCP connection where a client and server negotiated protocol version and capabilities before any tool calls happened.See also MCP, Mcp-Session-Id header, protocol version field-era wire.
Checkpoint. /mcp shows connected. claude mcp get counter prints the HTTP URL. If the row says pending approval, finish that prompt in the interactive session. Headless -p will not draw a form you can fill.
6. Fill the form after the first request already finished#
Ask Claude to increase the counter from 10. The amount dialog should appear. Look at wrangler dev before you type. The first POST is already complete. That is the proof the isolate is not sitting on the form.
Claude Code's docs say a call waiting on an open elicitation dialog is not backgrounded because the server is blocked on your input. That sentence is about the client holding the dialog. The Worker already returned. Mix those two up and you will keep a Durable Object around for a stream that is not there.
Submit 5. Confirm. The tool result should read Counter increased by 5; next value is 15. Three Worker requests, two forms, one number that actually moved.
If you are building the server from nothing rather than adding elicitation to one you already have, the older from-scratch MCP server post is the generic shell. This one is only the ask.
When it breaks#

Official docs walk the happy path. The hours go into the three failures that look like a hung Worker and are not.
- Wrong era. A handshake-era client (≤ 2025-11-25) has no
InputRequiredResult. FastMCP names it in one breath.Tool 'book_flight' returned an InputRequiredResult to request client input, but the multi-round-trip result type (SEP-2322) only exists at MCP 2026-07-28; this connection negotiated '2025-11-25'.Cloudflare'slegacy: "reject"lane refuses that client instead of shimming. Update Claude Code past 2.1.232, or you are debugging a protocol the Worker already declined to speak - Tampered state. No
MRTR_REQUEST_STATE_KEYthrows at boot. A blob the client edited fails with-32602 Invalid or expired requestStatebefore your tool runs. That is the codec doing its job. If you mint{ step: "confirmation" }before the human accepted the amount, anyone who echoes the token gets the step. Mint only what the previous round already proved - Fabricated decline. GitHub issue 89858 is the ugly one. Drive Claude Code with
--input-format stream-jsonunder a controlling client that never registeredonElicitation, and the SDK answers{ action: "decline" }with no prompt drawn
The spec defines decline as the human explicitly refusing. The server cannot tell a real no from a client that never asked. Related issues hit the VS Code extension and a TUI path that logged print-mode by accident.
Prove the form in the interactive REPL. Handle any action other than accept as cancel, not as a recorded human decision.
A fourth miss is copying elicitInput from McpAgent onto this handler. Pushed elicitation/create fails immediately on the stateless path. The isolate will not hang. The call just dies, which is ruder and much cheaper.
What now exists#
A Worker at /mcp that returns input_required on the first increase-counter call, finishes that request, and waits for nothing. Claude Code shows the amount form, then the confirm form. The signed requestState is the only memory between those POSTs. The next value prints. The isolate was not held while you typed.
If the first wrangler line still sits in ok after you submitted, you shipped the 2025 stream by accident. Tear out the await.
MCP elicitation, asked for real
Does a form-mode ask still need a Durable Object?
No. createMcpHandler builds a fresh server per request. Form-mode MCP elicitation returns input_required and the Worker request ends. Keep a Durable Object, D1, or KV for application data such as a cart or a third-party token from URL mode. The protocol session is not that store.
Why did the tool get decline when no form appeared?
Claude Code's interactive session draws the dialog. A controlling client on --input-format stream-json with no onElicitation callback answers decline and never shows a prompt. Treat action other than accept as cancel, and prove the form in the REPL first.
Can the form ask for an API key?
No. Form mode must not request passwords, API keys, access tokens, or payment credentials. Use URL mode for those, and expect a real token store on the server because the secret never rides back through the client.
asked on modelcontextprotocol.io ↗What happens if requestState is missing or wrong?
The TypeScript codec verifies HMAC-SHA256 state before the handler runs. Tampered or expired blobs fail with JSON-RPC -32602 Invalid or expired requestState. A missing MRTR_REQUEST_STATE_KEY throws at boot instead, MRTR_REQUEST_STATE_KEY must be configured.
Which Claude Code version speaks the 2026-07-28 retry?
The elicitation dialog shipped in 2.1.76. The v2 runtime that adds protocol revision 2026-07-28 on HTTP servers is 2.1.232 or later. Add the Worker with claude mcp add --transport http and check /mcp for connected.
