UNPKG

@modelcontextprotocol/server-everything

Version:

MCP server that exercises all the features of the MCP protocol

300 lines (299 loc) • 13.7 kB
// The server side of the Tasks extension (`io.modelcontextprotocol/tasks`, // SEP-2663) on the 2026-07-28 revision (#4852). // // A tool that runs as a task answers `tools/call` with a task handle // (`resultType: "task"`) instead of its result. The client then polls // `tasks/get` for the task's status, answers an `input_required` status with // `tasks/update`, and may stop the task with `tasks/cancel`; the final result // arrives inside the `completed` status. The SDK has no server implementation // of the extension (modelcontextprotocol/ext-tasks#30), so this module is one, // for the everything server's three task tools. // // A task outlives the request that created it, and on the 2026-07-28 HTTP // path every request is served by a fresh server instance, so tasks live in a // process-wide store keyed by the task id. The id is a random UUID the server // mints: it is the handle, and knowing it is what lets a client reach the // task. Nothing runs in the background. A task's status is worked out from the // clock when it is read, so there are no timers to stop; a task past its TTL // is dropped the next time the store is touched. // // The extension is served on 2026-07-28 only. The 2025-11-25 revision had its // own, different task methods in the core protocol; they are not served. import { randomUUID } from "node:crypto"; import { CLIENT_CAPABILITIES_META_KEY, MissingRequiredClientCapabilityError, ProtocolError, ProtocolErrorCode, } from "@modelcontextprotocol/server"; import { z } from "zod"; /** The extension's identifier, as declared in `capabilities.extensions`. */ export const TASKS_EXTENSION = "io.modelcontextprotocol/tasks"; /** Every live task, by id, across every server instance in the process. */ const tasks = new Map(); /** * The most tasks the process keeps. A task holds its tool's arguments (each * task tool bounds their length) and the client's answers (bounded by the * transports' request size) until its TTL, so without a cap a burst of calls * could exhaust memory first. A live task is kept until its TTL, since the * extension lets a server discard one only after that, so at the cap a new * task is refused rather than an old one dropped. */ export const MAX_TASKS = 100; /** * The most bytes of answers, serialized as JSON and counted in UTF-8, one * `tasks/update` may hand a task. * A task keeps its answers until its TTL, and a transport accepts messages * far larger than any of these tasks needs (10 MiB on stdio), so with * `MAX_TASKS` this bounds what the store holds. */ export const MAX_ANSWER_BYTES = 64 * 1024; /** Drop every task past its TTL. */ function sweep(now) { for (const [taskId, task] of tasks) { if (task.createdAt + task.ttlMs <= now) tasks.delete(taskId); } } /** The task with this id, or a -32602 error naming it as unknown. */ function find(taskId, now) { sweep(now); const task = tasks.get(taskId); if (!task) { throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Unknown task: ${taskId}`); } return task; } /** The task's state now, honoring a cancellation. */ function stateOf(task, now) { return task.cancelledAt !== undefined ? { status: "cancelled", statusMessage: "Cancelled by the client", since: task.cancelledAt, } : task.behavior.stateAt(now); } /** The wire form of a task (the `DetailedTask` of the extension's schema). */ function describe(task, now) { const state = stateOf(task, now); return { taskId: task.taskId, status: state.status, ...(state.statusMessage !== undefined && { statusMessage: state.statusMessage, }), createdAt: new Date(task.createdAt).toISOString(), lastUpdatedAt: new Date(Math.max(task.createdAt, state.since)).toISOString(), ttlMs: task.ttlMs, pollIntervalMs: task.pollIntervalMs, ...(state.status === "input_required" && { inputRequests: state.inputRequests, }), ...(state.status === "completed" && { result: state.result }), }; } /** * Store a new task and return the `tools/call` result that hands its handle * to the client: the task's fields, with `resultType: "task"`. * * @param behavior - How the task progresses. * @param options - Its time-to-live and the polling interval to suggest. */ export function createTask(behavior, options) { const now = Date.now(); sweep(now); if (tasks.size >= MAX_TASKS) { return { content: [ { type: "text", text: `The server is already running ${MAX_TASKS} tasks, its limit. Try again once one has expired.`, }, ], isError: true, }; } const task = { taskId: randomUUID(), createdAt: now, ...options, behavior, }; tasks.set(task.taskId, task); // The SDK passes a tool result's `resultType` through to the wire on // 2026-07-28, which is how a task handle replaces the tool's result. return { content: [], resultType: "task", ...describe(task, now) }; } /** * The capabilities the client declared in this request's `_meta` envelope, * where a 2026-07-28 client sends them with every request. */ export function clientCapabilitiesOf(ctx) { // The SDK's published typing of the envelope has no members, so it is read // as the plain JSON object it is. const envelope = ctx.mcpReq.envelope; const capabilities = isObject(envelope) ? envelope[CLIENT_CAPABILITIES_META_KEY] : undefined; return isObject(capabilities) ? capabilities : undefined; } /** * Whether the client that sent this request supports form-mode elicitation: * `elicitation.form`, or an `elicitation` object with neither mode, which the * spec reads as form mode (#4985). */ export function clientSupportsFormElicitation(ctx) { const elicitation = clientCapabilitiesOf(ctx)?.elicitation; return (isObject(elicitation) && (elicitation.form !== undefined || elicitation.url === undefined)); } /** * Whether the client that sent this request declared the tasks extension, so * it can follow a task handle. A tool may answer with a task only then. */ export function clientSupportsTasks(ctx) { const extensions = clientCapabilitiesOf(ctx)?.extensions; return isObject(extensions) && extensions[TASKS_EXTENSION] !== undefined; } function isObject(value) { return typeof value === "object" && value !== null && !Array.isArray(value); } /** The tasks extension, as a client declares it. */ const TASKS_CAPABILITY = { extensions: { [TASKS_EXTENSION]: {} }, }; /** * What each task tool needs the client to have declared: the tasks * extension, to follow the handle, and the capability its task asks for * input with. The SDK checks the input requests a tool returns itself, but * not the ones a task carries in its status, so they are checked here. */ const TASK_TOOL_REQUIREMENTS = { "simulate-research-query": (ctx) => missingTasks(ctx), "trigger-sampling-request-async": (ctx) => merged(missingTasks(ctx), clientCapabilitiesOf(ctx)?.sampling === undefined ? { sampling: {} } : undefined), "trigger-elicitation-request-async": (ctx) => merged(missingTasks(ctx), clientSupportsFormElicitation(ctx) ? undefined : { elicitation: { form: {} } }), }; /** The tasks extension, if this request's client did not declare it. */ function missingTasks(ctx) { return clientSupportsTasks(ctx) ? undefined : TASKS_CAPABILITY; } /** Both sets of missing capabilities, or undefined when neither is. */ function merged(a, b) { return a || b ? { ...a, ...b } : undefined; } /** The -32021 error naming the capabilities the client did not declare. */ function missingCapabilities(requiredCapabilities) { return new MissingRequiredClientCapabilityError({ requiredCapabilities }, `Missing required client capability: ${Object.keys(requiredCapabilities).join(", ")}`); } /** * Refuse a call to a task tool from a client that did not declare what the * tool needs, with the JSON-RPC error -32021 naming it, as the extension * requires: such a client cannot be served without a task handle. * * `McpServer` reports anything a tool handler throws as an `isError` tool * result, so the check cannot live in the tools. Instead it wraps the * `tools/call` handler `McpServer` installs, which the SDK then runs behind * its own era checks, so the error reaches the client as an error. Call this * before the first tool is registered, on a server constructed without a * `tools` capability: `McpServer` installs the handler at the first * registration then, or in its constructor when `tools` is declared. * * @param {McpServer} server - The 2026-07-28 server whose task tools to guard. */ export function guardTaskTools(server) { const lowLevel = server.server; const setRequestHandler = lowLevel.setRequestHandler.bind(lowLevel); // The SDK's overloads cannot be restated for a wrapper; this one only // inspects the arguments and passes them through unchanged. lowLevel.setRequestHandler = ((...args) => { const handler = args.at(-1); if (args[0] === "tools/call" && typeof handler === "function") { args[args.length - 1] = (request, ctx) => { const missing = TASK_TOOL_REQUIREMENTS[request.params.name]?.(ctx); if (missing) throw missingCapabilities(missing); return handler(request, ctx); }; } return setRequestHandler(...args); }); } /** Refuse a tasks/* request from a client without the extension (-32021). */ function requireTasks(ctx) { const missing = missingTasks(ctx); if (missing) throw missingCapabilities(missing); } /** Forget every task. For tests. */ export function clearTasks() { tasks.clear(); } const TaskIdParams = z.object({ taskId: z.string().describe("The task identifier"), }); const TaskResult = z.looseObject({}); const EmptyResult = z.object({}); /** * Serve the extension's methods on a 2026-07-28 server: `tasks/get`, * `tasks/update` and `tasks/cancel`. A request from a client that does not * declare the extension is refused with -32021. An unknown or expired task * id is refused with -32602 (invalid params). A cancellation is acknowledged * whatever the task's status, and changes only a task still running. An update's answers to requests that are not outstanding are * ignored. Each input request a task makes has a key of its own, never reused * once answered (the extension requires it), so a re-asked request gets a * new key. * * @param {McpServer} server - The 2026-07-28 server to serve them on. */ export function registerTasksExtension(server) { server.server.setRequestHandler("tasks/get", { params: TaskIdParams, result: TaskResult }, async ({ taskId }, ctx) => { requireTasks(ctx); const now = Date.now(); return describe(find(taskId, now), now); }); server.server.setRequestHandler("tasks/update", { params: TaskIdParams, result: EmptyResult }, async ({ taskId }, ctx) => { requireTasks(ctx); // The SDK lifts `inputResponses` out of the params of every request // (it is a reserved retry field on 2026-07-28) into the context. The // extension requires it, so a request without it is malformed. const inputResponses = ctx.mcpReq.inputResponses; if (inputResponses === undefined) { throw new ProtocolError(ProtocolErrorCode.InvalidParams, "Invalid params for tasks/update: inputResponses is required"); } const now = Date.now(); const task = find(taskId, now); const state = stateOf(task, now); // Only answers to requests outstanding now are passed on. The rest (a // key never issued, one already answered, or any answer to a task not // waiting for input) are ignored, as the extension asks, so a client // that resends or batches responses is not failed for it. const outstanding = state.status === "input_required" ? Object.keys(state.inputRequests) : []; const answered = Object.fromEntries(Object.entries(inputResponses).filter(([key]) => outstanding.includes(key))); // The limit is in bytes, so count the UTF-8 encoding of the serialized // answers: a string's length counts UTF-16 code units, which under-counts // any non-ASCII text by up to 3x. const size = Buffer.byteLength(JSON.stringify(answered), "utf8"); if (size > MAX_ANSWER_BYTES) { throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Invalid params for tasks/update: the answers are ${size} bytes, over the ${MAX_ANSWER_BYTES}-byte limit; the task is unchanged`); } if (Object.keys(answered).length > 0) { task.behavior.receive(answered, now); } return {}; }); server.server.setRequestHandler("tasks/cancel", { params: TaskIdParams, result: EmptyResult }, async ({ taskId }, ctx) => { requireTasks(ctx); const now = Date.now(); const task = find(taskId, now); // Cancellation is cooperative: the request is acknowledged whatever // the task's status, and a task that has already finished stays as it // ended. const { status } = stateOf(task, now); if (status !== "completed" && status !== "cancelled") { task.cancelledAt = now; } return {}; }); }