ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

todos-server 全功能 MCP 参考服务器实战:从 8 个工具到双协议双传输的完整实现

2026/9/15 12:42:10 拓冰建站 浏览量
todos-server 全功能 MCP 参考服务器实战:从 8 个工具到双协议双传输的完整实现 todos-server 全功能 MCP 参考服务器实战从 8 个工具到双协议双传输的完整实现【免费下载链接】typescript-sdkThe official TypeScript SDK for Model Context Protocol servers and clients项目地址: https://gitcode.com/GitHub_Trending/ty/typescript-sdktodos-server 是 TypeScript SDKModel 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.tsstart:http即tsx server.ts --http。stdio 模式下服务器在 stdin/stdout 上说话自己的诊断信息走 stderrHTTP 模式下它通过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 focusops ← 以斜杠命令形式调用 MCP 提示词支持 Tab 补全 todos:todos://board whats next? ← 把资源作为上下文挂进来 /watch todos:todos://board ← 订阅看板一变就出现通知 do all my tasks ← 逐任务进度 日志通知实时流出 (Ctrl-C mid-run) ← 取消工具提前停止模型被告知 clear my completed tasks ← elicitation 确认的批量删除 /help /servers /tools /resources /prompts /roots四、功能总览每个服务端特性住在哪里原文档用一张表格完整交代了什么演示什么完整继承如下服务端特性所在位置说明Toolsadd_task、add_tasks、list_tasks、complete_task纯 CRUDadd_task还按outputSchema返回structuredContentSamplingprioritize、brainstorm_tasks服务器借用宿主的模型宿主会先展示请求供批准Elicitation表单clear_done、brainstorm_tasksschema 驱动的表单accept / decline / cancel 全部处理多轮 input_requiredbrainstorm_tasks主题数量表单 → 可选自定义数量轮 → 采样轮状态挂在requestState上作为按步骤区分的联合类型step-discriminated union经createRequestStateCodecHMAC 签名进度 取消work_through_tasks、add_tasks逐任务节奏化进度通知work_through_tasks在任务之间检查ctx.mcpReq.signal宿主取消时提前停止日志每个变更工具经ctx.mcpReq.log2025 连接上遵从logging/setLevel2026-07-28 上遵从按请求的日志级别_meta选择加入Resourcestodos://board、todos://tasks/{id}一个具体资源 一个带补全回调的ResourceTemplate订阅看板资源2025 时代客户端走resources/subscribe/unsubscribe2026-07-28 走subscriptions/listen路由每次变更都会通知list_changed每次变更资源列表 资源更新通知在 stdio 与按请求 HTTP 上都正确投递Prompts completionsplan-my-day、seed-boardcompletable()参数值项目名、主题接到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与structuredContentserver.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则演示了批量 进度每插入一条任务假装耗时 100msawait new Promise(resolve setTimeout(resolve, 100))让宿主有在途进度可渲染complete_task支持按 id 或标题子串匹配list_tasks支持statusopen/done/all与project过滤。每个变更工具都调用announceBoardChange()和logInfo()把改状态和通报绑定在一起。5.2 资源具体资源 带补全的模板todos://board是具体资源把整个看板渲染为 markdownmimeType: text/markdownserver.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-boardtheme参数从一份固定主题列表space-station maintenance、wizard tower chores……按前缀过滤补全plan-my-dayfocus参数从当前看板的实际项目列表projects()补全返回值是一段多角色消息序列user → assistant → user用于播种一段围绕当前看板的规划对话。在 cli-client 里提示词以/todos:plan-my-day focusops这样的斜杠命令调用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 Elicitationschema 驱动的表单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 签名的 requestStatebrainstorm_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 用 WebCryptosubtle.verifybind 标签用定长 XOR 累加比较失败原因只暴露malformed / mac / expired / bind这种不透明码。关键点verify由 seam 在 handler进入之前运行篡改以冻结的-32602拒绝handler 通过类型化的ctx.mcpReq.requestStateBrainstormState()访问器读取已解码的 payload无需二次解码。todos.ts 中的工厂构造如下const stateCodec createRequestStateCodecBrainstormState({ key: process.env.REQUEST_STATE_SECRET ?? crypto.getRandomValues(new Uint8Array(32)) });即密钥来自环境变量未设置时退化为每进程随机密钥——对单进程服务整个流程的演示足够因为同 key 必须对所有可能收到回显requestState的服务器实例可用。代码中stateCodec.mint(payload, ctx)与ctx.mcpReq.requestStateT()构成类型化的编码/读取配对。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 所述的脚本化 e2eclient.tsCI 入口用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),仅供参考