/** * The "todos" demo application — the workload cli-client connects to out of the box. * * It is a small but believable application (a project todo board) where every MCP feature has * a job: CRUD tools the model calls from chat, the board and each task exposed as resources, * planning/seeding prompts, a sampling-backed `prioritize` tool that borrows the *host's* * model, elicitation-confirmed `clear_done` and `brainstorm_tasks`, and logging/progress while * it works. State is in-memory and per-process; the point is the wiring, not the persistence. * The transport entry point that serves this over stdio / Streamable HTTP is ../server.ts. */ import type { CallToolResult, ElicitRequestFormParams, InputRequiredResult, McpRequestContext, ServerContext } from '@modelcontextprotocol/server'; import { acceptedContent, completable, createRequestStateCodec, inputRequired, McpServer, ResourceTemplate } from '@modelcontextprotocol/server'; import * as z from 'zod/v4'; /** * The brainstorm_tasks flow as an explicit state machine: each variant names the round the * handler is waiting on and carries exactly the data the next round needs. The handler * dispatches on `step`, not on which `inputResponses` key happens to be present. */ type BrainstormState = | { step: 'awaiting-count' } | { step: 'awaiting-custom-count'; topic: string } | { step: 'awaiting-ideas'; topic: string; count: number }; /** * HMAC-signs the `requestState` round-tripped through brainstorm_tasks' multi-round flow so a * client cannot forge or mutate the carried step/theme/count. The seam runs `verify` before the * handler (rejecting tampered state with -32602) and the handler reads the decoded payload via * the typed `ctx.mcpReq.requestState()` accessor — no second decode. * The key comes from the environment for real deployments and falls back to a per-process * random one for the zero-setup demo (which is fine because one process serves every round). */ const stateCodec = createRequestStateCodec({ key: process.env.REQUEST_STATE_SECRET ?? crypto.getRandomValues(new Uint8Array(32)) }); /** Read the `action` from a raw elicitation `inputResponses` entry (decline/cancel detection). */ function elicitAction(response: unknown): 'accept' | 'decline' | 'cancel' { const action = typeof response === 'object' && response !== null && 'action' in response ? response.action : undefined; return action === 'accept' || action === 'decline' ? action : 'cancel'; } /** Read the text content from a raw sampling (`createMessage`) `inputResponses` entry. */ function sampledText(response: unknown): string { const content = typeof response === 'object' && response !== null && 'content' in response ? response.content : undefined; return typeof content === 'object' && content !== null && 'type' in content && content.type === 'text' && 'text' in content ? String(content.text) : ''; } interface Task { id: string; title: string; project: string; priority?: 'high' | 'medium' | 'low'; due?: string; notes?: string; status: 'open' | 'done'; } let nextId = 1; const tasks = new Map(); function addTask(task: Omit): Task { const created: Task = { id: `t${nextId++}`, status: 'open', ...task }; tasks.set(created.id, created); return created; } function openTasks(): Task[] { return [...tasks.values()].filter(task => task.status === 'open'); } function projects(): string[] { return [...new Set([...tasks.values()].map(task => task.project))]; } function describeTask(task: Task): string { const details = [task.priority && `priority: ${task.priority}`, task.due && `due: ${task.due}`, task.notes].filter(Boolean).join(', '); return `- [${task.status === 'done' ? 'x' : ' '}] ${task.title} (${task.id}, ${task.project}${details ? `; ${details}` : ''})`; } function renderBoard(): string { const done = [...tasks.values()].filter(task => task.status === 'done'); return [ '# Todo board', '', '## Open', ...openTasks().map(task => describeTask(task)), '', '## Done', ...done.map(task => describeTask(task)) ].join('\n'); } async function logInfo(ctx: ServerContext, text: string): Promise { // Request-tied logging: honours the client's logging/setLevel threshold on 2025-era // connections and the per-request logLevel opt-in on 2026-07-28 connections. await ctx.mcpReq.log('info', text, 'todos'); } /** * Over per-request HTTP serving, instance-level notifications have nowhere to go — * cross-request events are published through the handler's notifier instead. The transport * entry (../server.ts) wires these up for the HTTP branch; over stdio they stay unset and the * pinned instance's own notifications are routed by the serving entry. */ let publishBoardChanged: (() => void) | undefined; let publishBoardUpdated: ((uri: string) => void) | undefined; export function onBoardChanged(publish: () => void): void { publishBoardChanged = publish; } export function onBoardUpdated(publish: (uri: string) => void): void { publishBoardUpdated = publish; } async function reportProgress(ctx: ServerContext, progress: number, total: number, message: string): Promise { const progressToken = ctx.mcpReq._meta?.progressToken; if (progressToken === undefined) return; await ctx.mcpReq.notify({ method: 'notifications/progress', params: { progressToken, progress, total, message } }); } const CLEAR_CONFIRM_SCHEMA: ElicitRequestFormParams['requestedSchema'] = { type: 'object', properties: { confirm: { type: 'boolean', title: 'Delete all completed tasks?', description: 'This cannot be undone.' } }, required: ['confirm'] }; const BRAINSTORM_COUNT_SCHEMA: ElicitRequestFormParams['requestedSchema'] = { type: 'object', properties: { theme: { type: 'string', title: 'Theme for the invented tasks', default: "an engineer's week in hell" }, count: { type: 'string', title: 'How many tasks should I invent?', enum: ['5', '10', '20', '50', 'custom'] } }, required: ['count'] }; const BRAINSTORM_CUSTOM_COUNT_SCHEMA: ElicitRequestFormParams['requestedSchema'] = { type: 'object', properties: { customCount: { type: 'integer', title: 'Custom amount', minimum: 1, maximum: 100 } }, required: ['customCount'] }; function buildBrainstormSampling(topic: string, wanted: number) { return { systemPrompt: 'You invent short, funny todo items for a given theme. For engineering-flavored themes, lean into in-jokes like ' + '"Migrate the galactron database to omegastar" or "Ensure the tiddlywinks service speaks gRPC". ' + 'Reply with one task per line, no numbering, no commentary.', messages: [ { role: 'user' as const, content: { type: 'text' as const, text: `Invent ${wanted} todo tasks for the theme "${topic}".` } } ], maxTokens: Math.min(200 + wanted * 40, 1500) }; } /** What the server claims to be doing while it "works through" a task — pure colour for the log stream. */ const WORK_QUIPS = [ 'applying percussive maintenance', 'turning it off and on again', 'blaming DNS first, investigating second', 'negotiating with the load balancer', 'consulting the rubber duck for a second opinion', 'writing the postmortem in advance to save time', 'adding a TODO to remove the TODO', 'rolling back the rollback' ]; /** Parse an elicited count value (a preset like "10" or a custom number) into a usable number. */ function parseBrainstormCount(raw: unknown): number | undefined { const count = Number.parseInt(String(raw), 10); return Number.isNaN(count) || count < 1 || count > 100 ? undefined : count; } /** Match the LLM's ranking (one title per line) back to tasks; unmentioned tasks keep their order at the end. */ function applyRanking(rankingText: string, candidates: Task[]): Task[] { const remaining = [...candidates]; const ranked: Task[] = []; for (const line of rankingText.split('\n')) { const normalized = line.toLowerCase(); const index = remaining.findIndex(task => normalized.includes(task.title.toLowerCase())); if (index !== -1) ranked.push(...remaining.splice(index, 1)); } return [...ranked, ...remaining]; } function priorityForRank(rank: number, total: number): Task['priority'] { if (rank < Math.ceil(total / 3)) return 'high'; if (rank < Math.ceil((2 * total) / 3)) return 'medium'; return 'low'; } export function buildServer(reqCtx: McpRequestContext): McpServer { const server = new McpServer( { name: 'todos', version: '1.0.0' }, { capabilities: { logging: {}, resources: { listChanged: true, subscribe: true } }, requestState: { verify: stateCodec.verify }, instructions: 'todos is a small project todo board (it starts empty). Use list_tasks to see the board, add_task / add_tasks and complete_task to ' + 'change it, prioritize to rank the open tasks, brainstorm_tasks to invent themed example tasks, work_through_tasks to finish every ' + 'open task with progress updates, and clear_done to remove finished ones (it asks the user for confirmation). The full board is ' + 'also available as the todos://board resource, and it can be watched/subscribed to for change notifications. ' + 'When the user greets you or asks what to try, suggest this tour: 1) ask to brainstorm tasks (the server asks how many — ' + 'elicitation — then borrows the host model — sampling), 2) ask to prioritize the open tasks (sampling), 3) run the plan-my-day ' + 'prompt, 4) attach the todos://board resource as context and ask about it, 5) say "do all my tasks" and watch the progress and ' + 'log notifications, 6) ask to clear completed tasks (an elicitation-confirmed bulk delete). Watching the board resource ' + '(/watch in cli-client) shows live change notifications along the way.' } ); // Per-resource subscriptions: 2025-era clients call resources/subscribe (tracked here so // updates only go to subscribers); 2026-07-28 clients use a subscriptions/listen filter and // the serving entry routes the same notification onto it. const subscribedUris = new Set(); server.server.setRequestHandler('resources/subscribe', request => { subscribedUris.add(request.params.uri); return {}; }); server.server.setRequestHandler('resources/unsubscribe', request => { subscribedUris.delete(request.params.uri); return {}; }); /** Tell connected clients the board changed: the resource list, and the board resource for watchers. */ const announceBoardChange = async (): Promise => { await server.sendResourceListChanged(); if (publishBoardUpdated) { // Per-request HTTP serving: cross-request delivery goes through the entry's notifier. publishBoardChanged?.(); publishBoardUpdated('todos://board'); } else if (reqCtx.era === 'modern' || subscribedUris.has('todos://board')) { // stdio: the serving entry routes this onto open listen subscriptions (2026-07-28); // on 2025-era connections it goes out unsolicited, so only send it to subscribers. await server.server.sendResourceUpdated({ uri: 'todos://board' }).catch(() => {}); } }; server.registerResource( 'board', 'todos://board', { description: 'The whole todo board as markdown', mimeType: 'text/markdown' }, async uri => ({ contents: [{ uri: uri.href, mimeType: 'text/markdown', text: renderBoard() }] }) ); server.registerResource( 'task', new ResourceTemplate('todos://tasks/{id}', { list: async () => ({ resources: [...tasks.values()].map(task => ({ uri: `todos://tasks/${task.id}`, name: task.title, mimeType: 'text/markdown' })) }), complete: { id: value => [...tasks.keys()].filter(id => id.startsWith(value)) } }), { description: 'A single task by id', mimeType: 'text/markdown' }, async (uri, variables) => { const task = tasks.get(String(variables.id)); return { contents: [ { uri: uri.href, mimeType: 'text/markdown', text: task ? describeTask(task) : `No task with id ${String(variables.id)}` } ] }; } ); server.registerPrompt( 'seed-board', { description: 'Have the assistant invent themed example tasks and add them to the board (via add_tasks)', argsSchema: z.object({ theme: completable(z.string().describe('A theme for the invented tasks'), value => [ 'space-station maintenance', 'wizard tower chores', 'startup launch week', "engineer's week in hell", 'robot uprising prep' ].filter(theme => theme.startsWith(value)) ) }) }, async ({ theme }) => ({ messages: [ { role: 'user', content: { type: 'text', text: `Invent five short, funny todo tasks for the theme "${theme}" and add them to my board with the add_tasks tool (use "${theme}" as the project). Then show me the board.` } } ] }) ); server.registerPrompt( 'plan-my-day', { description: 'Seed a planning conversation around the current board', argsSchema: z.object({ focus: completable(z.string().describe('Project to focus on'), value => projects().filter(project => project.startsWith(value)) ) }) }, async ({ focus }) => ({ messages: [ { role: 'user', content: { type: 'text', text: `Here is my current todo board:\n\n${renderBoard()}` } }, { role: 'assistant', content: { type: 'text', text: 'Got it — I can see your board. What should today look like?' } }, { role: 'user', content: { type: 'text', text: `Plan my day around the "${focus}" project: pick at most three tasks, in order, and say why each one is next.` } } ] }) ); server.registerTool( 'add_task', { description: 'Add a task to the board', inputSchema: z.object({ title: z.string().describe('What needs doing'), project: z.string().optional().describe('Project bucket, e.g. "ops"'), priority: z.enum(['high', 'medium', 'low']).optional(), due: z.string().optional().describe('Free-form due date, e.g. "Friday"'), notes: z.string().optional() }), outputSchema: z.object({ id: z.string(), title: z.string(), status: z.enum(['open', 'done']) }) }, async ({ title, project, priority, due, notes }, ctx) => { const task = addTask({ title, project: project ?? 'inbox', priority, due, notes }); await announceBoardChange(); await logInfo(ctx, `added ${task.id}: ${task.title}`); return { content: [{ type: 'text', text: `Added ${task.id}: ${describeTask(task)}` }], structuredContent: { id: task.id, title: task.title, status: task.status } }; } ); server.registerTool( 'add_tasks', { description: 'Add several tasks to the board at once', inputSchema: z.object({ tasks: z .array( z.object({ title: z.string(), project: z.string().optional(), priority: z.enum(['high', 'medium', 'low']).optional(), due: z.string().optional(), notes: z.string().optional() }) ) .min(1) .describe('Tasks to add') }) }, async ({ tasks: newTasks }, ctx) => { const added: Task[] = []; for (const [index, task] of newTasks.entries()) { // Pretend each insert takes a moment so the host has in-flight progress to render. await new Promise(resolve => setTimeout(resolve, 100)); added.push(addTask({ ...task, project: task.project ?? 'inbox' })); await reportProgress(ctx, index + 1, newTasks.length, `added "${task.title}"`); } await announceBoardChange(); await logInfo(ctx, `added ${added.length} task(s)`); return { content: [{ type: 'text', text: `Added ${added.length} task(s):\n${added.map(task => describeTask(task)).join('\n')}` }] }; } ); server.registerTool( 'brainstorm_tasks', { description: 'Invent short, funny example tasks for a theme and add them to the board — asks the user how many (elicitation), then has the LLM connected to the host invent them (sampling)', inputSchema: z.object({ theme: z.string().optional().describe('Theme for the invented tasks (default: "an engineer\'s week in hell")') }) }, async ({ theme }, ctx): Promise => { // The theme can come from the model (tool argument) or from the user (the elicitation // form's theme field, pre-filled with a default); the user's answer wins. const fallbackTopic = theme ?? "an engineer's week in hell"; const resolveTopic = (raw: unknown): string => (typeof raw === 'string' && raw.trim().length > 0 ? raw.trim() : fallbackTopic); const countMessage = 'Let me invent some tasks for the board.'; const finish = async (ideasText: string, wanted: number, topic: string): Promise => { const titles = ideasText .split('\n') .map(line => line.replace(/^[-*\d.\s]+/, '').trim()) .filter(line => line.length > 0) .slice(0, wanted); if (titles.length === 0) { return { content: [{ type: 'text', text: 'The model did not return any task ideas.' }], isError: true }; } const added = titles.map(title => addTask({ title, project: topic })); await announceBoardChange(); await logInfo(ctx, `brainstormed ${added.length} task(s) for "${topic}"`); return { content: [ { type: 'text', text: `Added ${added.length} brainstormed task(s):\n${added.map(task => describeTask(task)).join('\n')}` } ] }; }; const declined = (action: string): CallToolResult => ({ content: [{ type: 'text', text: `Nothing added (user answered: ${action}).` }] }); // The whole conversation as a multi-round input_required chain — written ONCE. // The handler is a state machine over BrainstormState — it dispatches on // `state.step` (not on which inputResponses key arrived), so each round knows // exactly which answer to read and which data is in scope. State is HMAC-signed // by stateCodec; the seam verified integrity AND decoded the payload before this // handler ran — the typed accessor returns it. On a 2025-era session the SDK's // legacy shim fulfils each round as real push-style requests; the handler never // branches on the served era. const state = ctx.mcpReq.requestState(); const askForIdeas = async (count: number, topic: string): Promise => inputRequired({ inputRequests: { ideas: inputRequired.createMessage(buildBrainstormSampling(topic, count)) }, requestState: await stateCodec.mint({ step: 'awaiting-ideas', topic, count }, ctx) }); switch (state?.step) { case undefined: { // First call: ask for the theme and count. return inputRequired({ inputRequests: { count: inputRequired.elicit({ message: countMessage, requestedSchema: BRAINSTORM_COUNT_SCHEMA }) }, requestState: await stateCodec.mint({ step: 'awaiting-count' }, ctx) }); } case 'awaiting-count': { const response = ctx.mcpReq.inputResponses?.['count']; const accepted = acceptedContent<{ count?: string; theme?: string }>(ctx.mcpReq.inputResponses, 'count'); if (accepted === undefined) return declined(elicitAction(response)); const topic = resolveTopic(accepted.theme); if (accepted.count === 'custom') { return inputRequired({ inputRequests: { customCount: inputRequired.elicit({ message: 'How many exactly?', requestedSchema: BRAINSTORM_CUSTOM_COUNT_SCHEMA }) }, requestState: await stateCodec.mint({ step: 'awaiting-custom-count', topic }, ctx) }); } const wanted = parseBrainstormCount(accepted.count); if (wanted === undefined) return declined('cancel'); return askForIdeas(wanted, topic); } case 'awaiting-custom-count': { const response = ctx.mcpReq.inputResponses?.['customCount']; const accepted = acceptedContent<{ customCount?: number }>(ctx.mcpReq.inputResponses, 'customCount'); const wanted = parseBrainstormCount(accepted?.customCount); if (wanted === undefined) return declined(elicitAction(response)); return askForIdeas(wanted, state.topic); } case 'awaiting-ideas': { return finish(sampledText(ctx.mcpReq.inputResponses?.['ideas']), state.count, state.topic); } } } ); server.registerTool( 'list_tasks', { description: 'List tasks on the board', inputSchema: z.object({ status: z.enum(['open', 'done', 'all']).optional().describe('Which tasks to list (default: open)'), project: z.string().optional().describe('Only tasks in this project') }) }, async ({ status, project }) => { const wanted = status ?? 'open'; const matching = [...tasks.values()].filter( task => (wanted === 'all' || task.status === wanted) && (!project || task.project === project) ); return { content: [ { type: 'text', text: matching.length === 0 ? 'No matching tasks.' : matching.map(task => describeTask(task)).join('\n') } ] }; } ); server.registerTool( 'complete_task', { description: 'Mark a task as done', inputSchema: z.object({ task: z.string().describe('Task id, or part of its title') }) }, async ({ task: query }, ctx) => { const needle = query.toLowerCase(); const task = tasks.get(query) ?? [...tasks.values()].find(candidate => candidate.title.toLowerCase().includes(needle)); if (!task) { return { content: [{ type: 'text', text: `No task matches "${query}".` }], isError: true }; } task.status = 'done'; await announceBoardChange(); await logInfo(ctx, `completed ${task.id}: ${task.title}`); return { content: [{ type: 'text', text: `Marked "${task.title}" (${task.id}) as done.` }] }; } ); server.registerTool( 'work_through_tasks', { description: 'Work through every open task one by one (simulated, a few seconds each), logging what it is "doing", reporting progress, and marking each as done', inputSchema: z.object({ secondsPerTask: z.number().min(0).max(15).optional().describe('How long to pretend each task takes (default: 3 seconds)') }) }, async ({ secondsPerTask }, ctx) => { const queue = openTasks(); if (queue.length === 0) { return { content: [{ type: 'text', text: 'Nothing open — the board is already clear.' }] }; } const paceMs = (secondsPerTask ?? 3) * 1000; for (const [index, task] of queue.entries()) { // Honour cancellation: if the client aborted the call (notifications/cancelled), // stop early instead of ploughing through the rest of the queue. if (ctx.mcpReq.signal.aborted) { return { content: [ { type: 'text', text: `Stopped early — the request was cancelled after ${index} of ${queue.length} task(s).` } ] }; } // Narrate the "work" (a log notification per task), pretend it takes a moment so the // host has live progress to render, then announce the board change for watchers. await logInfo(ctx, `working on "${task.title}" — ${WORK_QUIPS[index % WORK_QUIPS.length] ?? 'working'}…`); await new Promise(resolve => setTimeout(resolve, paceMs)); task.status = 'done'; await reportProgress(ctx, index + 1, queue.length, `finished "${task.title}"`); await announceBoardChange(); } await logInfo(ctx, `worked through ${queue.length} open task(s)`); return { content: [ { type: 'text', text: `Worked through ${queue.length} task(s):\n${queue.map(task => `- ${task.title} ✔`).join('\n')}` } ] }; } ); server.registerTool( 'clear_done', { description: 'Delete every completed task (asks the user to confirm first)' }, async (ctx): Promise => { const done = [...tasks.values()].filter(task => task.status === 'done'); if (done.length === 0) return { content: [{ type: 'text', text: 'No completed tasks to clear.' }] }; const message = `Delete ${done.length} completed task(s) from the board?`; // A single input_required round, written once for both eras — the first call has // no inputResponses and returns the question; the re-call carries the answer. // (For multi-round flows, dispatch on a discriminated requestState instead — see // brainstorm_tasks.) On 2025-era sessions the SDK's legacy shim asks the question // as a real push-style elicitation. const response = ctx.mcpReq.inputResponses?.['confirmation']; if (response === undefined) { return inputRequired({ inputRequests: { confirmation: inputRequired.elicit({ message, requestedSchema: CLEAR_CONFIRM_SCHEMA }) } }); } const action = elicitAction(response); const confirmation = acceptedContent<{ confirm?: boolean }>(ctx.mcpReq.inputResponses, 'confirmation'); if (confirmation?.confirm !== true) { // Decline and cancel are answers — report them and stop, never ask again. return { content: [{ type: 'text', text: `Nothing deleted (user answered: ${action}).` }] }; } for (const task of done) tasks.delete(task.id); await announceBoardChange(); await logInfo(ctx, `cleared ${done.length} completed task(s)`); return { content: [{ type: 'text', text: `Deleted ${done.length} completed task(s).` }] }; } ); server.registerTool( 'prioritize', { description: 'Rank the open tasks by importance using the LLM connected to the host, and update their priorities' }, async (ctx): Promise => { const candidates = openTasks(); if (candidates.length === 0) return { content: [{ type: 'text', text: 'No open tasks to prioritize.' }] }; const samplingRequest = { systemPrompt: 'You prioritize todo lists. Reply with one task title per line, most important first. No commentary.', messages: [ { role: 'user' as const, content: { type: 'text' as const, text: `Rank these tasks:\n${candidates.map(task => `- ${task.title}`).join('\n')}` } } ], maxTokens: 400 }; // A single input_required round, written once for both eras (the ranking arrives // on the retried call), so no requestState is needed. For multi-round flows, // dispatch on a discriminated requestState instead — see brainstorm_tasks. On // 2025-era sessions the SDK's legacy shim performs the sampling round trip. const response = ctx.mcpReq.inputResponses?.['ranking']; if (response === undefined) { return inputRequired({ inputRequests: { ranking: inputRequired.createMessage(samplingRequest) } }); } const rankingText = sampledText(response); const ranked = applyRanking(rankingText, candidates); for (const [index, task] of ranked.entries()) { task.priority = priorityForRank(index, ranked.length); } // Priorities are board-visible state — watchers and list caches must hear about it. await announceBoardChange(); await logInfo(ctx, `prioritize: ranked ${ranked.length} open task(s) via the host LLM`); return { content: [ { type: 'text', text: `Re-prioritized ${ranked.length} task(s):\n${ranked.map(task => `- ${task.title} → ${task.priority}`).join('\n')}` } ] }; } ); return server; }