UNPKG

@modelcontextprotocol/server-everything

Version:

MCP server that exercises all the features of the MCP protocol

128 lines (127 loc) • 5.76 kB
// The `trigger-multi-step-elicitation` tool: asks the user two questions in // sequence, each depending on the last, to show `requestState` carrying a // multi-round-trip flow from one round to the next (#4852). // // Each retry of a multi-round-trip request carries only that round's answers, // and on the 2026-07-28 HTTP path a later round may be served by a fresh // server instance, so nothing on the server remembers earlier rounds. What // the flow has learned travels in `requestState` instead: an opaque string // the server returns with `input_required` and the client echoes back on the // retry. It is attacker-controlled on the way back, so the server signs it // and gives it an expiry (`roundStateCodec`); the SDK rejects a tampered or // expired state with -32602 before the handler runs. The handler switches on // the phase the state names rather than on which answers arrived. // // Written once for both protocol eras: for a 2025-era client the SDK's legacy // shim sends each round as a real `elicitation/create` request and replays // the state between rounds. import { acceptedContent, inputRequired, inputResponse, } from "@modelcontextprotocol/server"; import { z } from "zod"; import { roundStateCodec } from "../server/index.js"; import { TOOL_ICONS } from "../server/icons.js"; // Tool configuration const name = "trigger-multi-step-elicitation"; const config = { title: "Trigger Multi-Step Elicitation Tool", description: "Asks the user for their name, then for a favorite color addressed to them by that name, carrying " + "the first answer to the second round in requestState. Demonstrates multi-round-trip requests " + "(input_required) and requestState on protocol version 2026-07-28; 2025-era clients get the same " + "flow as elicitation requests.", inputSchema: {}, icons: TOOL_ICONS, annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false, }, }; /** The first question's answer: a name that is not blank. */ const NameSchema = z.object({ name: z.string().trim().min(1).describe("Your name"), }); /** The second question's answer. */ const ColorSchema = z.object({ color: z .enum(["red", "green", "blue"]) .describe("Your favorite of the three"), }); /** Ask for the name: the first round, and a repeat of it after a bad answer. */ const askForName = async (message) => inputRequired({ inputRequests: { name: inputRequired.elicit({ message, requestedSchema: NameSchema }), }, requestState: await roundStateCodec.mint({ step: "awaiting-name" }), }); /** Ask for the color, carrying the name the first round proved. */ const askForColor = async (name) => inputRequired({ inputRequests: { color: inputRequired.elicit({ message: `Thanks, ${name}. Which color do you like best?`, requestedSchema: ColorSchema, }), }, requestState: await roundStateCodec.mint({ step: "awaiting-color", name }), }); /** The result for a user who declined or cancelled a question. */ const stopped = (action, step) => ({ content: [ { type: "text", text: `Stopped: the user chose ${action} at ${step}.` }, ], isError: true, }); /** * Registers the 'trigger-multi-step-elicitation' tool. * * The tool needs a client that supports form-mode elicitation, so it is gated * like `trigger-elicitation-request`. Each entry reads the phase from the * verified `requestState`: * - none: the first call, which asks for the name; * - `awaiting-name`: reads the name, asking again if it is missing or blank, * then asks for the color with the name in the next state; * - `awaiting-color`: reads the color, asking again if it is not one of the * choices, then returns both answers. * A declined or cancelled question ends the flow with an error result. * * @param {McpServer} server - The McpServer instance where the tool will be registered. */ export const registerTriggerMultiStepElicitationTool = (server) => { server.registerTool(name, config, async (_args, ctx) => { const state = ctx.mcpReq.requestState(); const responses = ctx.mcpReq.inputResponses; switch (state?.step) { case "awaiting-name": { const view = inputResponse(responses, "name"); if (view.kind === "elicit" && view.action !== "accept") { return stopped(view.action, "the name question"); } const answer = acceptedContent(responses, "name", NameSchema); if (answer === undefined) { return askForName("A name is required. What is your name?"); } return askForColor(answer.name); } case "awaiting-color": { const view = inputResponse(responses, "color"); if (view.kind === "elicit" && view.action !== "accept") { return stopped(view.action, "the color question"); } const answer = acceptedContent(responses, "color", ColorSchema); const name = String(state.name); if (answer === undefined) return askForColor(name); return { content: [ { type: "text", text: `${name}'s favorite color is ${answer.color}.`, }, ], structuredContent: { name, color: answer.color }, }; } default: return askForName("What is your name?"); } }); };