Community-maintained FDE reference. Not an official Vercel or Anthropic project. About this project
Prompt: elicitation-server
You get an MCP server whose book_meeting tool pauses mid-call to ask the user for confirmation through the host, handling accept, decline, and cancel as three distinct outcomes; it teaches the flow from elicitation and the consent rules from consent UX. Wire status: the pinned stack (mcp-handler 2.1.1 on @modelcontextprotocol/server 2.0.0) serves the 2026-07-28 contract natively over Streamable HTTP and falls back to stateless 2025-11-25 Streamable HTTP for legacy clients; the SDK Client defaults to that legacy handshake unless you opt in to modern version negotiation, so the examples’ in-memory test suites exercise only the legacy path. Note that the 2026-07-28 revision replaces server-initiated elicitation pushes with input_required results (see the MRTR pattern), so the v2 SDK marks elicitInput deprecated; it remains the working path at the negotiated 2025-11-25 wire version this example runs on.
Copy everything in the block below into your AI coding agent as one message. See the prompt index for what makes these prompts reliable and how they were tested.
GOALBuild me a small TypeScript project called elicitation-server that runs locally and deploys to Vercel: an MCP (Model Context Protocol) server with one tool, book_meeting, that refuses to guess. Mid-execution it uses MCP elicitation to ask the user (through the host) for a confirmation and a meeting time, and it only books when the user explicitly approves. Decline, cancel, and an unapproved form submission must all book nothing.
STACK (exact, non-negotiable)Next.js App Router (next ^16, react and react-dom ^19), mcp-handler 2.1.1, @modelcontextprotocol/server 2.0.0 as a dependency (mcp-handler 2.1.1 peer-requires ^2.0.0), @modelcontextprotocol/client 2.0.0 as a devDependency (tests only), zod ^4.2.0 (HARD FLOOR: the v2 SDK requires zod 4.2.0 or newer; zod ^3 installs cleanly and then fails typecheck and tests), devDeps also typescript, vitest, @types/node. Node 22 or newer. package.json has "type": "module" and scripts dev (next dev), test (vitest run), typecheck (tsc --noEmit). Known status: SDK 2.0.0 still negotiates wire protocol 2025-11-25, where server-initiated elicitation is supported; elicitInput is marked deprecated for the 2026-07-28 era (input_required results replace it there) but is the correct working path on this stack today.
LAYOUTapp/api/mcp/route.ts is a thin shell (the old app/api/[transport]/ directory and the v1 createMcpHandler three-argument signature and basePath are gone in mcp-handler 2.x (the name survives with a new two-argument signature); the public endpoint stays /api/mcp): build createMcpHandler(configureServer, { serverInfo: { name: SERVER_NAME, version: SERVER_VERSION } }) from 'mcp-handler', wrap it as withOriginCheck(handler, parseAllowedOrigins(process.env[ALLOWED_ORIGINS_ENV])) (both from src/origin.ts), and export the wrapped function as GET, POST, DELETE. ALL protocol logic lives in src/server.ts exporting configureServer(server); the Origin allowlist lives in src/origin.ts. Tests live in tests/ (server.test.ts, origin.test.ts, vercel-config.test.ts), import only from src/ (or read vercel.json as data), and never import Next.js. Include a vercel.json with the $schema key and functions["app/api/mcp/route.ts"].maxDuration set to 30.
BEHAVIORsrc/server.ts exports SERVER_NAME 'elicitation-server', SERVER_VERSION '0.1.0', an exported bookings array of { topic, time }, and resetBookings() that empties it (module state on purpose so tests can assert no partial state).The topic argument is untrusted (a prompt-injected model calls the tool with adversarial arguments) and is about to be shown to a human inside a consent dialog, so bound it in the schema and REJECT (never truncate or escape) anything that could reshape the dialog. Export TOPIC_MAX_LENGTH = 120 and TOPIC_SCHEMA = z.string().min(1).max(TOPIC_MAX_LENGTH) with two refinements: no C0 or C1 control character (regex /[\u0000-\u001F\u007F-\u009F]/, so newline, carriage return, tab, ESC, DEL, and U+0080..U+009F are all rejected; message 'topic must not contain control characters') and no quote character (regex /["'`]/; message 'topic must not contain quote characters').configureServer registers exactly one tool, book_meeting, with inputSchema z.object({ topic: TOPIC_SCHEMA }) (v2 takes a full zod object schema, not a raw shape) and honest annotations { readOnlyHint: false, destructiveHint: false, idempotentHint: false } (booking writes a calendar entry, overwrites nothing, and each approved call books another meeting). The SDK validates arguments before the handler runs, so a bad topic never triggers an elicitation round trip. The handler calls server.server.elicitInput({ message, requestedSchema }) where message is a FIXED string constant containing NO model-supplied text, exactly: The book_meeting tool wants to add a meeting to your calendar. Review the topic it was given, choose a start time, and approve or reject. (it names the tool and the target system; the topic never lands in the prose).requestedSchema is FLAT and primitives-only (elicitation form mode forbids nested objects; type it as ElicitRequestFormParams['requestedSchema'] imported from '@modelcontextprotocol/server'): a top-level object built by a confirmBookingSchema(topic) helper with properties topic (type string, title 'Meeting topic', description 'Topic requested by the model. Edit it if it is wrong.', minLength 1, maxLength TOPIC_MAX_LENGTH, default set to the resolved topic argument, so the human reads and can correct it as a labeled field), approved (type boolean, title 'Approve', description 'Approve booking this meeting?') and time (type string, title 'Start time', description 'Preferred start time, e.g. 14:30', default '09:00'), with required: ['approved'].The result's action is 'accept', 'decline', or 'cancel', and only accept carries content. Handle all three distinctly:- accept with content.approved === true: take content.topic (falling back to the argument when the form omitted it) and run it through TOPIC_SCHEMA.safeParse; if it fails, book nothing and return text: Not booked: the confirmed topic is empty, too long, or contains characters that are not allowed. Otherwise read content.time, and if it is not a string fall back to '09:00'; push { topic: confirmedTopic, time } to bookings (what gets booked is what the human confirmed, not what the model asked for); return text: Booked: meeting about 'CONFIRMED_TOPIC' at TIME.- accept with approved not true: an explicit no; return text: Not booked: user did not approve the meeting about 'TOPIC'.- decline: return text: Not booked: user declined the meeting about 'TOPIC'.- cancel (or anything else): return text: Not booked: user cancelled the request to book 'TOPIC'.Refusals are NOT errors: all of these outcomes return ordinary isError-falsy tool results so hosts render them as answers, not failures. Never treat cancel or decline as consent, and never book on any path except an approved accept whose confirmed topic passes the bounds.src/origin.ts is the Origin allowlist (framework-free: Fetch Request in, Response or null out; the MCP transport spec requires Origin validation as the DNS-rebinding defense, and mcp-handler 2.x does not do it for you). Export ALLOWED_ORIGINS_ENV = 'MCP_ALLOWED_ORIGINS'; DEFAULT_ALLOWED_ORIGINS = ['http://localhost:3000', 'http://127.0.0.1:3000']; parseAllowedOrigins(raw) (comma separated, each entry normalized to new URL(entry).origin, unparseable entries dropped, duplicates removed, defaults used only when raw is undefined or blank, so an all-junk value yields an EMPTY allowlist rather than the default); assertAllowedOrigin(request, allowlist) (no Origin header: return null and let it through; Origin present and on the allowlist after normalization: null; anything else, including the literal 'null' origin: a 403 text/plain Response with body 'Forbidden: Origin not allowed' that does not echo the allowlist); and withOriginCheck(handler, allowlist), which wraps a Fetch-style handler, preserves any extra parameters, and short-circuits with the refusal.
TESTS (vitest, deterministic, fully offline, no human in the loop)book_meeting calls elicitInput mid-execution, so there is no bare handler to call; wire a real Client (from '@modelcontextprotocol/client') to a McpServer over InMemoryTransport.createLinkedPair(), both InMemoryTransport and McpServer imported from '@modelcontextprotocol/server'. BEFORE connect, call client.registerCapabilities({ elicitation: {} }) (the server may only elicit from clients that opted in), then client.setRequestHandler('elicitation/create', handler) (v2 addresses handlers by method string, not by zod request schema). The handler stands in for the host UI plus the user: it records request.params and returns a canned ElicitResult (type from '@modelcontextprotocol/client') chosen per test. Call resetBookings() in beforeEach. On this in-memory path connect() performs the legacy initialize handshake at protocol version 2025-11-25; results carry no resultType and list results carry no ttlMs or cacheScope, so do not assert those. In SDK v2 (2.0.0) Client.callTool returns a plain CallToolResult (type from '@modelcontextprotocol/client'); the v1 legacy toolResult union is gone, so read result.content directly with no narrowing.Assert all of these in tests/server.test.ts:1. listTools shows exactly one tool named book_meeting whose inputSchema is an object with a string topic property.2. With canned { action: 'accept', content: { approved: true, time: '14:30' } }, the result is isError falsy with text exactly: Booked: meeting about 'Q3 roadmap' at 14:30. and bookings equals [{ topic: 'Q3 roadmap', time: '14:30' }] (the elicited time, not a guess). Also assert the server elicited exactly once with a flat primitives-only requestedSchema matching { type: 'object', properties: { topic: { type: 'string', title: 'Meeting topic', maxLength: 120, default: 'Q3 roadmap' }, approved: { type: 'boolean' }, time: { type: 'string', default: '09:00' } }, required: ['approved'] }.3. The consent message is fixed prose: the recorded elicitation message contains 'book_meeting' and 'calendar' and does NOT contain 'Q3 roadmap'.4. With { action: 'accept', content: { approved: true, time: '14:30', topic: 'Q4 roadmap' } } and argument 'Q3 roadmap', the text is exactly Booked: meeting about 'Q4 roadmap' at 14:30. and bookings equals [{ topic: 'Q4 roadmap', time: '14:30' }] (the topic the user corrected in the form is what gets booked).5. With { action: 'accept', content: { approved: true, time: '14:30', topic: 'bad\u001btopic' } }, isError falsy, the text starts with 'Not booked:', and bookings stays empty (the confirmed topic is held to the same bounds).6. With { action: 'accept', content: { approved: false, time: '14:30' } }, isError falsy, the 'did not approve' text, and bookings stays empty.7. With { action: 'decline' }, isError falsy, the 'declined' text, bookings empty.8. With { action: 'cancel' }, isError falsy, the 'cancelled' text, bookings empty, and the text differs from the accept path's text (cancel must never read as booked).9. Schema-invalid arguments on the KNOWN tool (topic: 42) come back as an isError: true tool RESULT (callTool does not throw), the handler never runs, so the elicitation handler recorded nothing and bookings stays empty.10. it.each over topics containing a newline, a carriage return, an ESC byte (\u001b), a C1 control character (\u0085), a NUL byte, a double quote, a single quote, and a backtick: each is isError true, the elicitation handler recorded nothing, and bookings stays empty (the rejection happens at argument validation, before any elicitation fires).11. Bounds: an empty topic and a 121-character topic are isError true with nothing elicited and nothing booked; a 120-character topic goes through (isError falsy, exactly one elicitation, booked with the elicited time). Rejection, not truncation, on overflow.12. Calling an UNKNOWN tool name REJECTS: expect(client.callTool({ name: 'nope', arguments: {} })).rejects.toThrow(/not found/i). This CHANGED from the v1 stack (which returned isError results for unknown tools); v2 restores the spec's protocol-error semantics.Every negative asserts the bookings array itself stayed empty, not just the reply text.tests/origin.test.ts (direct calls with plain Fetch Request objects, no server): no Origin header passes; an allowlisted origin passes, including 'HTTPS://APP.example' against ['https://app.example'] (scheme and host case are normalized); a non-allowlisted origin, a different scheme or port, the literal 'null' origin, and an unparseable origin each get a 403 whose body mentions origin but does not contain the allowlist; an empty allowlist refuses every Origin yet still passes an origin-less request; parseAllowedOrigins falls back to DEFAULT_ALLOWED_ORIGINS for undefined and blank input, splits on commas, trims, normalizes, drops junk and duplicates, and returns [] for an all-junk value; withOriginCheck never invokes the wrapped handler on refusal and forwards extra arguments on success.tests/vercel-config.test.ts: read vercel.json as data and assert functions['app/api/mcp/route.ts'].maxDuration is a positive integer, so a refactor that drops the entry fails here rather than on the first production timeout.
DEFINITION OF DONEnpm install, npm run typecheck, npm test all green. Then npm run dev and connect MCP Inspector (npx @modelcontextprotocol/inspector) with Streamable HTTP to http://localhost:3000/api/mcp and call book_meeting by hand; Inspector declares the elicitation capability and shows you the form. Optionally vercel deploy; no environment variables are required. The one optional variable, MCP_ALLOWED_ORIGINS (comma separated browser origins), matters only if a browser-based client will call the endpoint; non-browser clients send no Origin and are unaffected.
SOURCEShttps://modelcontextprotocol.io/specification/2026-07-28/client/elicitation for the elicitation flow and the flat-schema rule; https://modelcontextprotocol.io/specification/2026-07-28/basic/patterns/mrtr for the input_required pattern that replaces push elicitation in the 2026-07-28 era; https://vercel.com/docs/mcp/deploy-mcp-servers-to-vercel and https://github.com/vercel/mcp-handler for the hosting pieces; the reference implementation at examples/elicitation-server/ in this repository (a path relative to the repo root; the GitHub repository is private), compare against it if you get stuck.
GUARDRAILSTests must pass with no network access and no Vercel account. No dependencies beyond the stack list. Keep it small: one tool, two source files (src/server.ts, src/origin.ts), three test files (server, origin, vercel-config). Do not swap in zod ^3 or SDK 1.x, and do not add an app/api/[transport]/ directory. Never interpolate the model-supplied topic into the elicitation message; it travels only as the labeled topic form field, and the in-memory bookings array is a test convenience, not storage.Where to look now
- Prompt index - all thirteen prompts and the reliability notes.
examples/elicitation-server(in the repository) - the reference implementation this prompt rebuilds.- Examples index - what each example demonstrates.
Bibliography
- localhost:3000 - http://localhost:3000/api/mcp
- Model Context Protocol Specification, Elicitation - https://modelcontextprotocol.io/specification/2026-07-28/client/elicitation
- Model Context Protocol Specification, Multi Round Trip Requests - https://modelcontextprotocol.io/specification/2026-07-28/basic/patterns/mrtr
- Vercel Documentation - https://vercel.com/docs/mcp/deploy-mcp-servers-to-vercel
- vercel/mcp-handler, source code - https://github.com/vercel/mcp-handler
- Reference implementation, source code (this repository) -
examples/elicitation-server(in the repository)