todos-server 全功能 MCP 参考服务器实战:从 8 个工具到双协议双传输的完整实现
【免费下载链接】typescript-sdkThe official TypeScript SDK for Model Context Protocol servers and clients项目地址: https://gitcode.com/GitHub_Trending/ty/typescript-sdk
todos-server 是 TypeScript SDK(Model Context Protocol 官方 SDK)仓库中作为**参考服务器(reference server)**存在的示例包:一个麻雀虽小、五脏俱全的项目待办看板,让 MCP 的每一项服务端能力都有真实用途——工具负责改状态、资源负责暴露状态、提示词负责播种对话、采样借用宿主的大模型、elicitation 向用户提问、进度与日志贯穿执行过程、按资源订阅通报每一次变更。它同时承担2026-07-28 与 2025-11-25 两个协议版本的按连接协商,以及stdio 与 Streamable HTTP 两种传输。读完本文,你将掌握:如何运行并连接这个参考服务器、它如何用一份代码同时服务两个协议时代、多轮input_required与 HMAC 签名requestState的落地写法,以及每一项服务端 MCP 能力的源码级实现位置。
一、定位:MCP 服务器世界的 "polls app"
原文档(examples/todos-server/README.md)用一个非常贴切的比喻定义了它的定位:它是 MCP 服务器的 "polls app"——小到可以一口气读完,又真实到没有任何内容是刻意拼凑的。它是参考宿主cli-client开箱即连的标准负载,你可以把它当作一份"服务端 MCP 能力全清单"来阅读。
与仓库中其他"单特性故事"示例(如 examples/elicitation、examples/sampling)不同,todos-server 刻意设计为server-only包(见 package.json 中example.excluded与shapeExempt字段的注释),它没有自己的client.ts,端到端覆盖完全由 cli-client 的脚本化 e2e 在 CI 中驱动,横跨 stdio + HTTP 两种传输、两个协议时代。
整个包只有两个文件,职责划分极为清晰:
server.ts transport entry: serveStdio by default, createMcpHandler + node adapter behind --http todos.ts the application: state, tools, resources, prompts, subscriptions — every feature above二、运行它:一条命令,两种传输
从仓库根目录首次运行需先pnpm install && pnpm build:all,之后:
# stdio — 适用于把服务器作为子进程拉起的宿主 pnpm --filter @mcp-examples/todos-server start # Streamable HTTP — 适用于远程式连接(默认端口 3000;--port 可改) pnpm --filter @mcp-examples/todos-server start:http两个命令对应 package.json 中的两条脚本:start即tsx server.ts,start:http即tsx server.ts --http。stdio 模式下服务器在 stdin/stdout 上说话,自己的诊断信息走 stderr;HTTP 模式下它通过createMcpHandler的按请求(per-request)模型在http://127.0.0.1:3000/mcp提供服务。
命令行参数由共享工具 parseExampleArgs 解析:--http切换到 HTTP 传输,--port <N>覆盖端口(回退到$PORT环境变量,再回退到默认 3000)。
关键设计:服务器上没有任何"时代开关"。从 todos.ts 和 server.ts 可以看到,serveStdio与createMcpHandler在握手阶段自动检测每条连接的协议版本并据此固定(pin)实例——所以一个 2025 时代的客户端和一个 2026 时代的客户端可以同时跟同一个进程对话(HTTP 下甚至是并发的)。其底层机制可参见 serveStdio.ts 的文档注释:stdio 入口对连接的开场交换只分类一次,用同一个工厂按客户端开启的时代构建一个实例并为其终身固定,此后不再做任何逐消息时代分类;initialize握手(或任何无信封声明的消息)固定为 2025 时代会话,携带有效_meta信封的请求固定为现代实例,server/discover探测则先乐观构建现代实例、不立即固定,允许客户端回退到initialize。
三、把参考宿主连上来:两条终端、一条配置
3.1 HTTP 对连(完整演示)
# 终端 A — 用 Streamable HTTP 提供参考服务器(端口 3000) pnpm --filter @mcp-examples/todos-server start:http # 终端 B — 把宿主连上去(加上 provider key 可接真实模型) pnpm --filter @mcp-examples/cli-client start -- --server http://127.0.0.1:3000/mcp客户端的状态行会展示协商结果:connected to "todos" (2026-07-28, 8 tools, …);若在终端 B 追加--legacy强制 2025 时代握手,同一服务器上会看到(2025-11-25, …),运行的是每个功能的 legacy 分支。要精确锁死某个协议版本可用--protocol-version(如--protocol-version 2025-06-18),协商不到即连接失败。
如果想快速瞄一眼,甚至不需要 HTTP 步骤——cli-client不带任何参数运行时会自动通过 stdio 把 todos-server 作为子进程拉起。
3.2 任何 mcpServers 风格的宿主
{ "mcpServers": { "todos": { "command": "npx", "args": ["-y", "tsx", "/absolute/path/to/examples/todos-server/server.ts"] } } }注意路径必须是绝对路径,因为相对路径是相对于运行 CLI 的目录解析的(见 examples/cli-client/README.md 中对mcpServers配置的说明)。
3.3 一次坐下来的全功能巡游
cli-client 的 README 提供了一条"一坐到底"的巡游路线,几乎覆盖 todos-server 的每一项能力:
brainstorm some tasks ← elicitation 表单(主题 + 数量)+ 需批准的采样 prioritize my open tasks ← 采样:执行前先请你批准请求 /todos:plan-my-day focus=ops ← 以斜杠命令形式调用 MCP 提示词(支持 Tab 补全) @todos:todos://board what's next? ← 把资源作为上下文挂进来 /watch @todos:todos://board ← 订阅:看板一变就出现通知 do all my tasks ← 逐任务进度 + 日志通知实时流出 (Ctrl-C mid-run) ← 取消:工具提前停止,模型被告知 clear my completed tasks ← elicitation 确认的批量删除 /help /servers /tools /resources /prompts /roots四、功能总览:每个服务端特性住在哪里
原文档用一张表格完整交代了"什么演示什么",完整继承如下:
| 服务端特性 | 所在位置 | 说明 |
|---|---|---|
| Tools | add_task、add_tasks、list_tasks、complete_task | 纯 CRUD;add_task还按outputSchema返回structuredContent |
| Sampling | prioritize、brainstorm_tasks | 服务器借用宿主的模型;宿主会先展示请求供批准 |
| Elicitation(表单) | clear_done、brainstorm_tasks | schema 驱动的表单;accept / decline / cancel 全部处理 |
| 多轮 input_required | brainstorm_tasks | 主题+数量表单 → 可选自定义数量轮 → 采样轮;状态挂在requestState上作为按步骤区分的联合类型(step-discriminated union),经createRequestStateCodecHMAC 签名 |
| 进度 + 取消 | work_through_tasks、add_tasks | 逐任务节奏化进度通知;work_through_tasks在任务之间检查ctx.mcpReq.signal,宿主取消时提前停止 |
| 日志 | 每个变更工具,经ctx.mcpReq.log | 2025 连接上遵从logging/setLevel,2026-07-28 上遵从按请求的日志级别_meta选择加入 |
| Resources | todos://board、todos://tasks/{id} | 一个具体资源 + 一个带补全回调的ResourceTemplate |
| 订阅 | 看板资源 | 2025 时代客户端走resources/subscribe/unsubscribe;2026-07-28 走subscriptions/listen路由;每次变更都会通知 |
| list_changed | 每次变更 | 资源列表 + 资源更新通知,在 stdio 与按请求 HTTP 上都正确投递 |
| Prompts + completions | plan-my-day、seed-board | completable()参数值(项目名、主题)接到completion/complete |
两个协议时代如何差异地承载交互
两个协议时代在"交互对话如何在线缆上传输"上本质不同:2025 时代连接上,线缆承载的是推送式的elicitation/create/sampling/createMessage请求;2026-07-28 上,服务器返回input_required结果、客户端携带答案重试该调用。而 todos-server 的三个交互式工具(brainstorm_tasks、clear_done、prioritize)只用input_required风格写一遍——在 2025 时代连接上,SDK 默认开启的 legacy shim 为它们执行推送式的往返,所以任何 handler 里都没有时代分支。
这个"写一次、双时代运行"的关键就是 SDK 的 legacyInputRequiredShim.ts:当 handler 在 2025 时代请求上返回 input-required 结果时,shim 把每个内嵌请求作为真实的服务器→客户端请求发出(elicitation/create、sampling/createMessage、roots/list,并盖上源请求 id 以便流关联),收集inputResponses后重新进入 handler,直到它返回最终结果或轮数上限(默认 8 轮)耗尽。语义与现代客户端驱动完全对齐:每轮 REPLACED 的inputResponses、字节精确的requestState回显(每轮由配置的钩子重新验证)、仅 requestState 轮的节奏化等待——因此 handler 无法察觉自己由哪个时代履约。
唯一的服务模式注意事项:通过HTTP + 2025 时代客户端连接时,createMcpHandler默认的无状态(stateless)姿态没有推送式服务器→客户端请求的返回路径,所以采样/elicitation 工具在该腿(leg)上会干净地拒绝(stdio 不受影响;2026-07-28 的 HTTP 不受影响)。这与 examples/sampling/README.md 中提到的同一 caveat 一致,cli-client 的 CI 脚本也因此在 legacy HTTP 腿上跳过采样/elicitation 步骤。如需 2025 时代的会话式 HTTP 服务,可依据 createMcpHandler.ts 中isLegacyRequest谓词文档给出的模式,在用户层路由到既有的会话式传输实现。
五、从源码看每项能力的落地
5.1 工具:CRUD、批量与结构化输出
四个纯 CRUD 工具都在 todos.ts 的buildServer中注册。add_task值得细看的是它同时返回content与structuredContent:
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 } }; } );add_tasks则演示了批量 + 进度:每插入一条任务假装耗时 100ms(await new Promise(resolve => setTimeout(resolve, 100))),让宿主有在途进度可渲染;complete_task支持按 id 或标题子串匹配;list_tasks支持status(open/done/all)与project过滤。每个变更工具都调用announceBoardChange()和logInfo(),把"改状态"和"通报"绑定在一起。
5.2 资源:具体资源 + 带补全的模板
todos://board是具体资源,把整个看板渲染为 markdown(mimeType: 'text/markdown'):
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() }] }) );todos://tasks/{id}是一个ResourceTemplate,同时给出了列表回调(list,用于枚举现有任务资源)和补全回调(complete: { id: value => [...tasks.keys()].filter(id => id.startsWith(value)) },让客户端可以对任务 id 做参数补全):
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)}` }] }; } );5.3 提示词与补全
两个提示词都把参数声明为completable()——这正是 MCPcompletion/complete的数据源:
seed-board:theme参数从一份固定主题列表("space-station maintenance"、"wizard tower chores"……)按前缀过滤补全;plan-my-day:focus参数从当前看板的实际项目列表(projects())补全,返回值是一段多角色消息序列(user → assistant → user),用于播种一段围绕当前看板的规划对话。
在 cli-client 里,提示词以/todos:plan-my-day focus=ops这样的斜杠命令调用,Tab 会补全命令、提示词名、@server:uri提及以及提示词参数值——后者正是走 MCPcompletion/complete。
5.4 采样:借用宿主的模型
prioritize工具把当前所有打开的任务标题发给宿主模型,请它按重要度排序("Reply with one task title per line, most important first"),然后按排名把任务分成 high / medium / low 三档(priorityForRank用三等分法)。brainstorm_tasks的采样请求由buildBrainstormSampling构造:给定主题与数量,请求模型逐行输出创意任务,maxTokens随数量缩放(Math.min(200 + wanted * 40, 1500))。
在宿主侧,采样请求会先完整展示给你批准,批准后才通过驱动聊天的同一个LLMProvider.generate()路由出去——参考宿主的设计原则是"批准显式且失败即关闭(fail closed)":展示完整请求而非预览,并且无论服务器要求多少都封顶maxTokens(见 examples/cli-client/README.md 的 Design notes)。
5.5 Elicitation:schema 驱动的表单
clear_done用一个布尔确认表单请求删除授权:
const CLEAR_CONFIRM_SCHEMA: ElicitRequestFormParams['requestedSchema'] = { type: 'object', properties: { confirm: { type: 'boolean', title: 'Delete all completed tasks?', description: 'This cannot be undone.' } }, required: ['confirm'] };单轮input_required的写法是:首次调用没有inputResponses,返回问题;重调用携带答案。答案的 accept / decline / cancel 三种走向都被elicitAction识别——confirm !== true时报告"用户回答了什么"并停止,绝不追问第二次:
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) { return { content: [{ type: 'text', text: `Nothing deleted (user answered: ${action}).` }] }; }5.6 多轮 input_required:状态机 + HMAC 签名的 requestState
brainstorm_tasks是本文档中最值得研读的部分——它把整个对话实现为一个显式的多轮input_required链,共三至四轮:主题+数量表单 →(可选)自定义数量表单 → 采样轮 → 落盘。状态是一个按步骤区分的联合类型:
type BrainstormState = | { step: 'awaiting-count' } | { step: 'awaiting-custom-count'; topic: string } | { step: 'awaiting-ideas'; topic: string; count: number };handler 按state.step分派(而不是按"来了哪个inputResponses键"),所以每一轮都知道该读哪个答案、哪些数据在作用域内。requestState由createRequestStateCodec铸造(mint)并经stateCodec.verify验证——客户端无法伪造或篡改携带的 step/theme/count。其实现见 requestStateCodec.ts:
- 线缆格式为
"v1." b64url({"p":<payload>,"exp":<unixSeconds>,"b":<bindTag>?}) "." b64url(mac),MAC 覆盖版本前缀 + body,域分离的 bind 标签截断到 128 位; - 密钥要求≥ 32 字节(
RangeError于构造时抛出),ttlSeconds默认 600 秒(过期即拒绝),可选bind回调把状态绑定到认证主体/方法; - 它是签名而非加密:客户端可以 base64url 解码看到明文 payload,所以不要把机密放进 payload(注释明确建议需要机密性时用 AEAD 构造);
- 验证失败即关闭(fail-closed)且常量时间(body MAC 用 WebCrypto
subtle.verify,bind 标签用定长 XOR 累加比较),失败原因只暴露'malformed' / 'mac' / 'expired' / 'bind'这种不透明码。
关键点:verify由 seam 在 handler进入之前运行(篡改以冻结的-32602拒绝),handler 通过类型化的ctx.mcpReq.requestState<BrainstormState>()访问器读取已解码的 payload,无需二次解码。todos.ts 中的工厂构造如下:
const stateCodec = createRequestStateCodec<BrainstormState>({ key: process.env.REQUEST_STATE_SECRET ?? crypto.getRandomValues(new Uint8Array(32)) });即密钥来自环境变量,未设置时退化为每进程随机密钥——对"单进程服务整个流程"的演示足够(因为同 key 必须对所有可能收到回显requestState的服务器实例可用)。代码中stateCodec.mint(payload, ctx)与ctx.mcpReq.requestState<T>()构成类型化的编码/读取配对。
5.7 进度、取消与日志
- 进度:
reportProgress从ctx.mcpReq._meta?.progressToken读取令牌,然后发notifications/progress(携带progressToken, progress, total, message)。add_tasks和work_through_tasks都用它做节奏化进度播报。 - 取消:
work_through_tasks在任务之间检查ctx.mcpReq.signal.aborted——宿主通过RequestOptions.signal中止调用(SDK 会发notifications/cancelled),服务器据此提前停止并报告"已处理 X / N 个任务"。cli-client 侧对应 Ctrl-C 中断运行中的工具调用。 - 日志:
logInfo即ctx.mcpReq.log('info', text, 'todos'),请求绑定,在 2025 连接上遵从客户端的logging/setLevel阈值,在 2026-07-28 连接上遵从按请求的logLevel选择加入。work_through_tasks还内置了一份 8 条的打趣文案池("applying percussive maintenance"、"consulting the rubber duck for a second opinion"……),让日志流有真实感。
5.8 订阅与 list_changed
订阅逻辑按协议时代分两套,但都由announceBoardChange统一收口:
- 2025 时代客户端调用
resources/subscribe/resources/unsubscribe,服务器在subscribedUris集合里跟踪订阅者,更新只发给订阅者; - 2026-07-28 客户端使用
subscriptions/listen过滤器,由服务入口把同一通知路由到打开的监听流; - 每次变更调用
server.sendResourceListChanged(),并(视时代与传输)发送sendResourceUpdated({ uri: 'todos://board' })。
announceBoardChange中还体现了一个传输相关的设计决策:按请求 HTTP 服务没有向下的推送通道,跨请求事件(看板变化)改由 handler 的 notifier 发布——这正是 server.ts 中 HTTP 分支要做的事:
const handler = createMcpHandler(buildServer); onBoardChanged(() => handler.notify.resourcesChanged()); onBoardUpdated(uri => handler.notify.resourceUpdated(uri));stdio 分支则保持未设置,由固定实例自己的通知由服务入口路由。buildServer也据此接受reqCtx: McpRequestContext——在announceBoardChange里用reqCtx.era === 'modern'判断走哪条投递路径。
六、传输层:server.ts 的双入口骨架
server.ts 是标准的双传输骨架,与其他示例一致:
const { transport, port } = parseExampleArgs(); if (transport === 'stdio') { void serveStdio(buildServer); console.error('[todos] serving over stdio'); } else { const handler = createMcpHandler(buildServer); onBoardChanged(() => handler.notify.resourcesChanged()); onBoardUpdated(uri => handler.notify.resourceUpdated(uri)); const app = createMcpHonoApp(); app.all('/mcp', c => handler.fetch(c.req.raw)); serve({ fetch: app.fetch, port, hostname: '127.0.0.1' }, () => { console.error(`[todos] listening on http://127.0.0.1:${port}/mcp`); }); }要点:
- 同一个工厂
buildServer供两个入口共用。serveStdio(来自@modelcontextprotocol/server/stdio)与createMcpHandler(来自@modelcontextprotocol/server)都按服务单元调用该工厂:stdio 每个连接一次,HTTP 每个请求一次。这正是两个协议时代"永不漂移"的结构保证(见 createMcpHandler.ts 文档:"Use ONE factory for both legs")。 - 工厂接收
McpRequestContext,内含era: 'legacy' | 'modern'等字段(详见 createMcpHandler.ts 的McpRequestContext接口定义),实例据此构建并固定时代。 - HTTP 腿通过 Hono 框架承载:
createMcpHonoApp()默认绑定 localhost 的 host/origin 校验,app.all('/mcp', c => handler.fetch(c.req.raw))把 web-standard fetch 面接到 Hono 上,再用@hono/node-server的serve监听127.0.0.1:PORT。handler.notify是类型化的发布侧门面,把变更事件发布到每个已打开且选择接收该通知类型的订阅流(无订阅打开时安全空操作)。
从 createMcpHandler.ts 的源码还可以看到入口对内层路由的完整处理:对每个入站 HTTP 请求做恰好一次分类(body-primary),现代路径(带_meta信封)用新鲜实例 + 单交换 per-request 传输服务;无信封声明的请求(含initialize、GET/DELETE 会话操作、2025 通知 POST)按默认legacy: 'stateless'姿态逐请求服务,可选的legacy: 'reject'则打造现代-only 严格端点。入口本身不做 Origin/Host 校验(由中间件负责,Hono 工厂默认代劳)也不做 token 校验(authInfo严格透传)。
七、配置:两个环境变量
原文档的配置表完整继承如下:
| 环境变量 | 作用 |
|---|---|
REQUEST_STATE_SECRET | 签名requestState的 HMAC 密钥(≥ 32 字节)。未设置时服务器生成每进程随机密钥——只要单个进程服务整个流程就完全够用。 |
PORT | --port未传入时的 HTTP 端口(默认 3000)。 |
第一个变量的边界值得强调:createRequestStateCodec在构造时要求密钥至少 32 字节,且同一个密钥必须对所有可能收到回显requestState的服务器实例可用——所以多进程部署必须显式配置同一密钥,否则一个进程铸的 token 在另一个进程验证时会因 MAC 不符而被拒。
八、布局与测试覆盖
server.ts transport entry: serveStdio by default, createMcpHandler + node adapter behind --http todos.ts the application: state, tools, resources, prompts, subscriptions — every feature above本包刻意 server-only,其端到端覆盖来自 examples/cli-client/README.md 所述的脚本化 e2e:client.ts(CI 入口)用ScriptedProvider重放一段脚本化对话,逐步断言循环、命名空间、资源挂载、提示词角色处理、采样批准、多轮 elicitation + 签名requestState流程、补全、取消、进度与日志确实完成了往返——覆盖 stdio 与 Streamable HTTP、两个协议时代(进度/日志/订阅断言跑在时序确定的 stdio 腿上)。pnpm run:examples在 CI 中执行它。
结语
todos-server 的价值不在于功能多,而在于它把 MCP 服务端的每一项能力都用"真实业务"串了起来:CRUD 工具、资源与模板、提示词与补全、借用宿主模型的采样、schema 驱动的 elicitation、带 HMAC 签名状态的多轮 input_required、进度/取消/日志、按资源订阅与 list_changed——并且全部只写一遍,同时服务 2025-11-25 与 2026-07-28 两个协议时代、stdio 与 Streamable HTTP 两种传输。对想学习或复制 MCP 服务端最佳实践的开发者来说,它是一份可以通读的参考实现:先读 todos.ts 看应用层每项能力怎么注册,再读 server.ts 看双传输骨架怎么搭,最后对照 cli-client 看宿主侧如何消费这一切。
【免费下载链接】typescript-sdkThe official TypeScript SDK for Model Context Protocol servers and clients项目地址: https://gitcode.com/GitHub_Trending/ty/typescript-sdk
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考