feat(examples): add a reference host/server pair — cli-client + todos-server (#2380)
This commit is contained in:
parent
1823aae891
commit
ba27d201e1
4 changed files with 4698 additions and 25 deletions
81
examples/todos-server/README.md
Normal file
81
examples/todos-server/README.md
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
# todos-server — the reference MCP server
|
||||
|
||||
A small project todo board where **every server-side MCP feature has a real job**: tools that mutate state, resources that expose it, prompts that seed conversations, sampling that borrows the connected host's model, elicitation that asks the user, progress and logs while it works, and per-resource subscriptions that announce every change. It is the workload [`cli-client`](../cli-client/README.md) (the reference host) connects to out of the box — think of it as the "polls app" of MCP servers: small enough to read in one sitting, real enough that nothing in it is contrived.
|
||||
|
||||
It serves **both protocol revisions at once** — 2026-07-28 and 2025-11-25 are negotiated per connection, from the same code — and **both transports**: stdio and Streamable HTTP.
|
||||
|
||||
## Run it
|
||||
|
||||
From the repo root (first time: `pnpm install && pnpm build:all`):
|
||||
|
||||
```bash
|
||||
# stdio — for hosts that spawn their servers as child processes
|
||||
pnpm --filter @mcp-examples/todos-server start
|
||||
|
||||
# Streamable HTTP — for remote-style connections (default port 3000; --port to change)
|
||||
pnpm --filter @mcp-examples/todos-server start:http
|
||||
```
|
||||
|
||||
Over stdio the server speaks on stdin/stdout (its own diagnostics go to stderr). Over HTTP it serves `http://127.0.0.1:3000/mcp` via `createMcpHandler`'s per-request model.
|
||||
|
||||
There is no era flag on the server: `serveStdio` and `createMcpHandler` detect each connection's revision during the handshake and pin the instance accordingly, so a 2025-era client and a 2026-era client can talk to the same process — simultaneously, over HTTP.
|
||||
|
||||
## Connect cli-client to it
|
||||
|
||||
```bash
|
||||
# Two terminals: serve over HTTP, then point the reference host at it
|
||||
pnpm --filter @mcp-examples/todos-server start:http # terminal A
|
||||
pnpm --filter @mcp-examples/cli-client start -- --server http://127.0.0.1:3000/mcp # terminal B
|
||||
|
||||
# Same, but force the 2025-era handshake on the client to see the legacy arm in action
|
||||
pnpm --filter @mcp-examples/cli-client start -- --server http://127.0.0.1:3000/mcp --legacy
|
||||
```
|
||||
|
||||
The client's status line shows what was negotiated: `connected to "todos" (2026-07-28, 8 tools, …)` vs `(2025-11-25, …)`.
|
||||
|
||||
You don't need the HTTP step for a quick look — running `cli-client` with no arguments spawns this server over stdio automatically.
|
||||
|
||||
Any other `mcpServers`-style host can spawn it too:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"mcpServers": {
|
||||
"todos": { "command": "npx", "args": ["-y", "tsx", "/absolute/path/to/examples/todos-server/server.ts"] }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## What demonstrates what
|
||||
|
||||
| Server feature | Where it lives | Notes |
|
||||
| -------------------------- | ------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Tools | `add_task`, `add_tasks`, `list_tasks`, `complete_task` | plain CRUD; `add_task` also returns `structuredContent` against an `outputSchema` |
|
||||
| Sampling | `prioritize`, `brainstorm_tasks` | the server borrows the _host's_ model; the host shows the request for approval first |
|
||||
| Elicitation (form) | `clear_done`, `brainstorm_tasks` | schema-driven forms; accept / decline / cancel all handled |
|
||||
| Multi-round input_required | `brainstorm_tasks` | theme+count form → optional custom-amount round → sampling round; state rides `requestState` as a **step-discriminated union**, HMAC-signed via `createRequestStateCodec` |
|
||||
| Progress + cancellation | `work_through_tasks`, `add_tasks` | paced per-task progress notifications; `work_through_tasks` checks `ctx.mcpReq.signal` between tasks and stops early when the host cancels |
|
||||
| Logging | every mutating tool, via `ctx.mcpReq.log` | honours `logging/setLevel` on 2025 connections and the per-request log-level `_meta` opt-in on 2026-07-28 |
|
||||
| Resources | `todos://board`, `todos://tasks/{id}` | one concrete resource + a `ResourceTemplate` with a completion callback for task ids |
|
||||
| Subscriptions | the board | `resources/subscribe`/`unsubscribe` handlers for 2025-era clients; `subscriptions/listen` routing for 2026-07-28; every mutation notifies |
|
||||
| list_changed | every mutation | resource list + resource updated notifications, delivered correctly over stdio and per-request HTTP |
|
||||
| Prompts + completions | `plan-my-day`, `seed-board` | `completable()` argument values (project names, themes) wired to `completion/complete` |
|
||||
|
||||
The two protocol eras differ in how interactive tools converse with the client: on 2025-era connections the server _pushes_ `elicitation/create` / `sampling/createMessage` requests and awaits them inline; on 2026-07-28 it returns `input_required` results and the client retries the call with the answers. The interactive tools (`brainstorm_tasks`, `clear_done`, `prioritize`) implement both arms — branch on `reqCtx.era` to compare them side by side.
|
||||
|
||||
One serving-mode caveat: over **HTTP with a 2025-era client**, `createMcpHandler`'s default stateless posture has no return path for push-style server→client requests, so the sampling/elicitation tools refuse cleanly on that leg (stdio is unaffected; 2026-07-28 HTTP is unaffected).
|
||||
|
||||
## Configuration
|
||||
|
||||
| Env var | Effect |
|
||||
| ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `REQUEST_STATE_SECRET` | HMAC key for the signed `requestState` (≥ 32 bytes). Unset, the server generates a per-process random key — fine whenever a single process serves the whole flow. |
|
||||
| `PORT` | HTTP port when `--port` isn't passed (default 3000). |
|
||||
|
||||
## Layout
|
||||
|
||||
```text
|
||||
server.ts transport entry: serveStdio by default, createMcpHandler + node adapter behind --http
|
||||
todos.ts the application: state, tools, resources, prompts, subscriptions — every feature above
|
||||
```
|
||||
|
||||
This package is intentionally **server-only**; its end-to-end coverage comes from the [`cli-client`](../cli-client/README.md) scripted e2e, which drives it across stdio + HTTP on both protocol eras in CI.
|
||||
22
examples/todos-server/package.json
Normal file
22
examples/todos-server/package.json
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
{
|
||||
"name": "@mcp-examples/todos-server",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "tsx server.ts",
|
||||
"start:http": "tsx server.ts --http"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mcp-examples/shared": "workspace:*",
|
||||
"@modelcontextprotocol/node": "workspace:*",
|
||||
"@modelcontextprotocol/server": "workspace:*",
|
||||
"zod": "catalog:runtimeShared"
|
||||
},
|
||||
"devDependencies": {
|
||||
"tsx": "catalog:devTools"
|
||||
},
|
||||
"example": {
|
||||
"excluded": "server-only package — exercised end-to-end by the examples/cli-client e2e legs",
|
||||
"shapeExempt": "Reference server, not a single-feature story: no client.ts of its own; the paired host lives in examples/cli-client."
|
||||
}
|
||||
}
|
||||
29
examples/todos-server/server.ts
Normal file
29
examples/todos-server/server.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
/**
|
||||
* Transport entry point for the "todos" reference server (the application itself lives in
|
||||
* todos.ts). Same dual-transport skeleton as every other example: stdio by default
|
||||
* (cli-client spawns it as a child process), Streamable HTTP behind `--http`.
|
||||
*/
|
||||
import { createServer } from 'node:http';
|
||||
|
||||
import { parseExampleArgs } from '@mcp-examples/shared';
|
||||
import { toNodeHandler } from '@modelcontextprotocol/node';
|
||||
import { createMcpHandler } from '@modelcontextprotocol/server';
|
||||
import { serveStdio } from '@modelcontextprotocol/server/stdio';
|
||||
|
||||
import { buildServer, onBoardChanged, onBoardUpdated } from './todos';
|
||||
|
||||
const { transport, port } = parseExampleArgs();
|
||||
|
||||
if (transport === 'stdio') {
|
||||
void serveStdio(buildServer);
|
||||
console.error('[todos] serving over stdio');
|
||||
} else {
|
||||
const handler = createMcpHandler(buildServer);
|
||||
// Per-request serving has no connection to push notifications down — cross-request
|
||||
// events (the board changing) are published through the handler's notifier instead.
|
||||
onBoardChanged(() => handler.notify.resourcesChanged());
|
||||
onBoardUpdated(uri => handler.notify.resourceUpdated(uri));
|
||||
createServer(toNodeHandler(handler)).listen(port, () => {
|
||||
console.error(`[todos] listening on http://127.0.0.1:${port}/mcp`);
|
||||
});
|
||||
}
|
||||
713
examples/todos-server/todos.ts
Normal file
713
examples/todos-server/todos.ts
Normal file
|
|
@ -0,0 +1,713 @@
|
|||
/**
|
||||
* 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); the handler calls `verify` again to 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}).` }]
|
||||
});
|
||||
|
||||
if (reqCtx.era === 'legacy') {
|
||||
// 2025 era: push-style round trips, awaited inline — one elicitation for the theme
|
||||
// and count (with a follow-up form only when the user picks "custom"), then the
|
||||
// host's model invents the tasks (sampling).
|
||||
const countResult = await ctx.mcpReq.elicitInput({
|
||||
mode: 'form',
|
||||
message: countMessage,
|
||||
requestedSchema: BRAINSTORM_COUNT_SCHEMA
|
||||
});
|
||||
if (countResult.action !== 'accept') return declined(countResult.action);
|
||||
const topic = resolveTopic(countResult.content?.theme);
|
||||
let wanted = parseBrainstormCount(countResult.content?.count);
|
||||
if (countResult.content?.count === 'custom') {
|
||||
const customResult = await ctx.mcpReq.elicitInput({
|
||||
mode: 'form',
|
||||
message: 'How many exactly?',
|
||||
requestedSchema: BRAINSTORM_CUSTOM_COUNT_SCHEMA
|
||||
});
|
||||
if (customResult.action !== 'accept') return declined(customResult.action);
|
||||
wanted = parseBrainstormCount(customResult.content?.customCount);
|
||||
}
|
||||
if (wanted === undefined) return declined('cancel');
|
||||
const response = await ctx.mcpReq.requestSampling(buildBrainstormSampling(topic, wanted));
|
||||
const ideasText = !Array.isArray(response.content) && response.content.type === 'text' ? response.content.text : '';
|
||||
return finish(ideasText, wanted, topic);
|
||||
}
|
||||
|
||||
// 2026-07-28: the same conversation as a multi-round input_required chain. 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 already verified integrity before this handler ran, so verify here is
|
||||
// the decode.
|
||||
const state: BrainstormState | undefined =
|
||||
ctx.mcpReq.requestState === undefined ? undefined : await stateCodec.verify(ctx.mcpReq.requestState, ctx);
|
||||
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?`;
|
||||
|
||||
let action: string;
|
||||
let confirmation: { confirm?: boolean } | undefined;
|
||||
if (reqCtx.era === 'legacy') {
|
||||
// 2025 era: a push-style elicitation/create request, answered inline.
|
||||
const result = await ctx.mcpReq.elicitInput({ mode: 'form', message, requestedSchema: CLEAR_CONFIRM_SCHEMA });
|
||||
action = result.action;
|
||||
confirmation = result.action === 'accept' && result.content ? { confirm: result.content.confirm === true } : undefined;
|
||||
} else {
|
||||
// 2026-07-28: a single input_required round, so no requestState is needed —
|
||||
// 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.)
|
||||
const response = ctx.mcpReq.inputResponses?.['confirmation'];
|
||||
if (response === undefined) {
|
||||
return inputRequired({
|
||||
inputRequests: { confirmation: inputRequired.elicit({ message, requestedSchema: CLEAR_CONFIRM_SCHEMA }) }
|
||||
});
|
||||
}
|
||||
action = elicitAction(response);
|
||||
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
|
||||
};
|
||||
|
||||
let rankingText: string;
|
||||
if (reqCtx.era === 'legacy') {
|
||||
// 2025 era: push-style sampling/createMessage back to the client, awaited inline.
|
||||
const response = await ctx.mcpReq.requestSampling(samplingRequest);
|
||||
rankingText = !Array.isArray(response.content) && response.content.type === 'text' ? response.content.text : '';
|
||||
} else {
|
||||
// 2026-07-28: a single input_required round (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.
|
||||
const response = ctx.mcpReq.inputResponses?.['ranking'];
|
||||
if (response === undefined) {
|
||||
return inputRequired({ inputRequests: { ranking: inputRequired.createMessage(samplingRequest) } });
|
||||
}
|
||||
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;
|
||||
}
|
||||
Loading…
Reference in a new issue