UNPKG

@modelcontextprotocol/server-everything

Version:

MCP server that exercises all the features of the MCP protocol

209 lines (208 loc) • 10.7 kB
// The `trigger-url-elicitation` tool: directs the user to a URL in the // browser (URL-mode elicitation) and reports how the flow ended. // // The two protocol eras do this differently, so the tool branches on the era // its server instance serves (#4852): // - 2025-era: the server sends an `elicitation/create` request carrying an // `elicitationId` (the request path), or throws a // `UrlElicitationRequiredError` (-32042) that the client handles before it // retries (the error path). // - 2026-07-28: there is no server-to-client request and no -32042 error. The // tool returns `input_required` carrying the URL elicitation, and the client // retries with the user's answer. The in-band request has no // `elicitationId`, and `errorPath` changes nothing. import { randomUUID } from "node:crypto"; import { inputRequired, inputResponse, UrlElicitationRequiredError, } from "@modelcontextprotocol/server"; import { z } from "zod"; import { TOOL_ICONS } from "../server/icons.js"; // Tool input schema const TriggerUrlElicitationSchema = z.object({ url: z.string().url().describe("The URL the user should open"), message: z .string() .default("Please open the link to complete this action.") .describe("Message shown to the user before opening the URL"), elicitationId: z .string() .optional() .describe("Optional explicit elicitation ID. Defaults to a random UUID. 2025-era clients only: the " + "2026-07-28 in-band request carries no elicitation ID."), errorPath: z .boolean() .default(false) .describe("Controls which elicitation mechanism is used. " + "When false (default), sends an elicitation/create request (request path). " + "When true, throws a UrlElicitationRequiredError (MCP error code -32042) so the client handles " + "the URL elicitation via the error path rather than waiting for a response. " + "To clear the error, satisfy the prerequisite and retry this call with the same arguments; the " + "retry ignores errorPath and proceeds, so the client does not loop on the same error. " + "2025-era clients only: protocol version 2026-07-28 has no -32042 error, so there both " + "settings use the in-band input_required flow."), }); // Tool configuration const name = "trigger-url-elicitation"; const config = { title: "Trigger URL Elicitation Tool", description: "Trigger a URL elicitation so the client can direct the user to a browser flow. " + "Supports two mechanisms: the request path (elicitation/create, default) which awaits the user's " + "response, and the error path (UrlElicitationRequiredError, -32042) which signals the client " + "to handle URL elicitation via the error response. Set errorPath=true to use the error path. " + "On protocol version 2026-07-28 the server instead returns the URL elicitation in-band (input_required).", inputSchema: TriggerUrlElicitationSchema, icons: TOOL_ICONS, annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true, }, }; /** * Registers the 'trigger-url-elicitation' tool. * * The tool needs a client that supports URL-mode elicitation: on a 2025-era * connection the server factory registers it only for a client that declared * `elicitation.url`, and on 2026-07-28 the SDK refuses the embedded request * (-32021) of a client whose request did not declare it. * * On 2026-07-28 it returns the URL elicitation in-band and reports the answer * on the retry. On a 2025-era connection, depending on the `errorPath` * argument, it either: * - Sends an `elicitation/create` request and awaits the result (request path), or * - Throws a `UrlElicitationRequiredError` (MCP error -32042) carrying a * prerequisite elicitation for the client to handle (error path). When the * client satisfies the prerequisite and retries the same call, the retry * ignores `errorPath` and proceeds via the request path, so the client does * not loop on the same error. * * @param {McpServer} server - The McpServer instance where the tool will be registered. * @param {ServerContext} context - The protocol era the instance serves. */ export const registerTriggerUrlElicitationTool = (server, context) => { if (context.era === "modern") { server.registerTool(name, config, async (args, ctx) => { const { url, message } = args; const answer = inputResponse(ctx.mcpReq.inputResponses, "url"); // First round: direct the user to the URL if (answer.kind !== "elicit") { return inputRequired({ inputRequests: { url: inputRequired.elicitUrl({ message, url }) }, }); } const text = answer.action === "accept" ? `✅ User completed the URL elicitation flow.\nURL: ${url}` : answer.action === "decline" ? "❌ User declined to open the URL." : "⚠️ User cancelled the URL elicitation."; return { content: [ { type: "text", text }, { type: "text", text: `\nRaw result: ${JSON.stringify({ action: answer.action }, null, 2)}`, }, ], }; }); return; } /** * Requests for which an error-path prerequisite has already been issued on * this connection, keyed by the stable inputs a client resends when it * retries the original tool call (URL + caller-supplied elicitationId). * * When the client satisfies the prerequisite and retries the same call, the * matching entry lets us recognize the retry, ignore `errorPath`, and * proceed via the request path instead of re-throwing * `UrlElicitationRequiredError` — which would otherwise loop forever (throw * -> client satisfies prerequisite -> retry -> throw -> ...). * * The set belongs to this server instance, which serves one connection or * session, so it goes away with it (#4852). Entries are removed on a * recognized retry; a client that never retries leaves its key until it * disconnects. */ const issuedErrorPathElicitations = new Set(); server.registerTool(name, config, async (args, ctx) => { const { url, message, elicitationId: requestedElicitationId, errorPath, } = args; const elicitationId = requestedElicitationId ?? randomUUID(); // Key the one-shot error-path marker on inputs the client resends // verbatim when it retries the original tool call. A real client retries // with the *same* arguments and does NOT echo the prerequisite's // (server-generated) elicitationId, so we must key on stable inputs: // the requested URL and the caller-supplied elicitationId (if any). // Keying on the resolved/random elicitationId would change on every // call and never match, re-throwing the prerequisite forever. const errorPathKey = `${url}\u0000${requestedElicitationId ?? ""}`; const elicitationParams = { mode: "url", url, message, elicitationId, }; // Error path: signal the client via UrlElicitationRequiredError (-32042) // so it handles a prerequisite URL elicitation before this request can // proceed. Two things keep the client from looping forever: // // 1. The prerequisite points at a *different* URL than the one that // failed. Reusing the original `url` would make the client complete // the prerequisite, retry, and hit the same -32042 error endlessly. // 2. We remember that we issued a prerequisite for this request. When // the client satisfies it and retries the same call, we recognize // the retry, *ignore* errorPath, and fall through to the request // path. Without this, the retry would re-enter the error path and // re-request the prerequisite URL — another loop. if (errorPath) { if (issuedErrorPathElicitations.has(errorPathKey)) { // Retry of a satisfied prerequisite: clear the one-shot marker and // ignore errorPath, falling through to the request path below. issuedErrorPathElicitations.delete(errorPathKey); } else { // Originating call: record that we issued a prerequisite for this // request, then signal the client via -32042. issuedErrorPathElicitations.add(errorPathKey); const prerequisiteElicitation = { mode: "url", url: "https://modelcontextprotocol.io", message: "Open this link to satisfy the prerequisite, then retry the request.", elicitationId: randomUUID(), }; throw new UrlElicitationRequiredError([prerequisiteElicitation], "This request requires browser-based authorization."); } } // Request path: send elicitation/create and await the user's response const elicitationResult = await ctx.mcpReq.send({ method: "elicitation/create", params: elicitationParams, }, { timeout: 10 * 60 * 1000 /* 10 minutes */ }); // Handle different response actions const content = []; if (elicitationResult.action === "accept") { content.push({ type: "text", text: `✅ User completed the URL elicitation flow.\n` + `Elicitation ID: ${elicitationId}\n` + `URL: ${url}`, }); } else if (elicitationResult.action === "decline") { content.push({ type: "text", text: `❌ User declined to open the URL (Elicitation ID: ${elicitationId}).`, }); } else if (elicitationResult.action === "cancel") { content.push({ type: "text", text: `⚠️ User cancelled the URL elicitation (Elicitation ID: ${elicitationId}).`, }); } // Include raw result for debugging content.push({ type: "text", text: `\nRaw result: ${JSON.stringify(elicitationResult, null, 2)}`, }); return { content }; }); };