typescript-sdk/examples/todos-server/todos.ts

674 lines
31 KiB
TypeScript

/**
* 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<BrainstormState>()` 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<BrainstormState>({
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<string, Task>();
function addTask(task: Omit<Task, 'id' | 'status'>): 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<void> {
// 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<void> {
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<string>();
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<void> => {
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<CallToolResult | InputRequiredResult> => {
// 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<CallToolResult> => {
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<BrainstormState>();
const askForIdeas = async (count: number, topic: string): Promise<InputRequiredResult> =>
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<CallToolResult | InputRequiredResult> => {
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<CallToolResult | InputRequiredResult> => {
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;
}