
1. 为什么我要用 TypeScript 手写一个 MCP stdio 服务端MCP 全称 Model Context Protocol模型上下文协议它做的事情说人话就是给 AI 应用和外部程序之间定一套统一的说话方式。你写一个工具函数只要按 MCP 的规矩暴露出去Claude Desktop、Cursor、VS Code 这些支持 MCP 的客户端就能发现它、调用它不用你为每个 AI 应用单独写一套适配层。适合谁适合手里有一堆脚本、内部 API、数据库查询逻辑想让大模型直接调用的开发者也适合想搞明白 MCP 底层到底怎么通信、不想只停留在“装个插件”层面的人。MCP 的通信方式主要有两种stdio 和 http。stdio 就是标准输入输出服务端进程从 stdin 读 JSON-RPC 请求往 stdout 写 JSON-RPC 响应。它高效、简洁缺点是只能本地进程间通信没法跨网络。http 可以远程但配置和鉴权更麻烦。入门阶段我强烈建议从 stdio 开始因为你能亲眼看到每一行请求和响应调试成本最低。通信格式是 JSON-RPC 2.0。一个请求长这样{ jsonrpc: 2.0, method: sum, params: { a: 5, b: 6 }, id: 1 }对应响应{ jsonrpc: 2.0, result: 11, id: 1 }MCP 在这套 JSON-RPC 基础上做了进一步规范定义了 initialize、tools/list、tools/call 这些固定方法。你要做的就是实现这些方法让客户端能完成握手、发现工具、调用工具三步。这篇文章我会用 TypeScript npm 从零搭一个可调试的 stdio MCP 服务端再配一个本地客户端脚本验证请求响应最后用 MCP Inspector 做可视化调试。全程可复制跑完你就有第一个能用的 MCP 工具。2. 环境准备与 TaoToken 前置配置让客户端有模型可调写 MCP 服务端本身不需要模型但你要验证“AI 应用真的能调用我的工具”就得有一个能连大模型的客户端。我实测下来最省事的路径是先把模型接入配置好再让支持 MCP 的客户端去连你的服务端。这里用 TaoToken 做模型接入层它的 API 地址是 https://taotoken.net/api兼容 OpenAI 风格的调用方式配置一次就能在多个客户端里复用。第一步去控制台创建 API Key。打开 https://taotoken.net/console?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content 登录后在 API Keys 页面新建一个密钥复制出来先存到本地环境变量里别硬编码进代码export TAOTOKEN_API_KEYsk-你的密钥第二步确认你要用的模型 ID。不同客户端对模型名的写法略有差异但核心就是 Base URL Key Model ID 三件套。你可以先在模型对话页面试一下连通性https://taotoken.net/model-chat?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content 随便发一句话能正常返回就说明 Key 和模型都没问题。第三步如果你打算长期用 MCP 做编码或 Agent 类任务可以看下 Coding Planhttps://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content 。它更适合高频调用场景比按次计费更划算。接入文档在 https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content 里面有各客户端的配置示例。这里要强调一个概念区分MCP Host 是 AI 应用本身比如 Claude Desktop、CursorMCP Client 是 Host 内部用来和服务端通信的组件通常每启动一个 MCP Server 就开一个 Client。你配好模型接入是为了让 Host 有“大脑”你写 MCP Server是为了给 Host 装“手脚”。两者配合工具调用链路才完整。3. 可复制配置npm 工程初始化与 tsconfig、package.json 全量片段先建目录、初始化 npm 工程。我习惯把 MCP 服务端单独放一个包方便以后发布到 npm 用 npx 跑mkdir mcp-stdio-demo cd mcp-stdio-demo npm init -y npm install modelcontextprotocol/sdk zod npm install -D typescript tsx types/nodemodelcontextprotocol/sdk是官方 SDK封装了 stdio 传输和 JSON-RPC 消息处理zod用来定义工具入参的 schemaSDK 会把它转成 JSON Schema 暴露给客户端。接着改package.json关键是type设为module并加上bin字段方便以后 npx 执行{ name: mcp-stdio-demo, version: 1.0.0, type: module, bin: { mcp-stdio-demo: dist/index.js }, scripts: { build: tsc, dev: tsx src/index.ts, inspect: npx modelcontextprotocol/inspector node dist/index.js }, dependencies: { modelcontextprotocol/sdk: ^1.0.0, zod: ^3.23.8 }, devDependencies: { typescript: ^5.6.0, tsx: ^4.19.0, types/node: ^22.0.0 } }然后是tsconfig.json。MCP SDK 是 ESM 优先所以模块系统选NodeNext目标设ES2022{ compilerOptions: { target: ES2022, module: NodeNext, moduleResolution: NodeNext, outDir: dist, rootDir: src, strict: true, esModuleInterop: true, skipLibCheck: true, declaration: true }, include: [src/**/*] }这套配置我踩过的坑是如果module写成CommonJSSDK 的 ESM 导出会报ERR_REQUIRE_ESM。所以type: module和NodeNext必须成对出现。另外bin指向dist/index.js构建后记得在文件头加 shebang否则 npx 执行会提示找不到解释器。4. 服务端实现stdio 握手、工具注册与 JSON-RPC 请求响应在src/index.ts里写服务端。核心就三件事创建 Server 实例、注册工具、用 StdioServerTransport 连接。#!/usr/bin/env node import { Server } from modelcontextprotocol/sdk/server/index.js; import { StdioServerTransport } from modelcontextprotocol/sdk/server/stdio.js; import { CallToolRequestSchema, ListToolsRequestSchema, } from modelcontextprotocol/sdk/types.js; import { z } from zod; const server new Server( { name: mcp-stdio-demo, version: 1.0.0, }, { capabilities: { tools: {}, }, } ); // 工具入参 schema const SumArgsSchema z.object({ a: z.number().describe(第一个加数), b: z.number().describe(第二个加数), }); // 工具发现tools/list server.setRequestHandler(ListToolsRequestSchema, async () { return { tools: [ { name: sum, description: 计算两个数字之和, inputSchema: { type: object, properties: { a: { type: number, description: 第一个加数 }, b: { type: number, description: 第二个加数 }, }, required: [a, b], }, }, ], }; }); // 工具调用tools/call server.setRequestHandler(CallToolRequestSchema, async (request) { if (request.params.name sum) { const args SumArgsSchema.parse(request.params.arguments); const result args.a args.b; return { content: [ { type: text, text: String(result), }, ], }; } throw new Error(未知工具: ${request.params.name}); }); // 启动 stdio 传输 const transport new StdioServerTransport(); await server.connect(transport); console.error(MCP stdio server 已启动);这里有几个细节值得说。第一console.error而不是console.log因为 stdout 被 JSON-RPC 占用了任何多余的 stdout 输出都会污染协议流导致客户端解析失败。第二capabilities里声明tools: {}客户端才知道你支持工具能力。第三工具返回必须是content数组每项有type和对应字段文本就是type: text。构建并运行npm run build node dist/index.js进程会挂起等待 stdin 输入这是正常的。你可以手动喂一条 initialize 请求测试echo {jsonrpc:2.0,id:1,method:initialize,params:{protocolVersion:2024-11-05,capabilities:{},clientInfo:{name:test,version:1.0.0}}} | node dist/index.js如果看到返回的serverInfo和capabilities说明握手成功。这一步是整个链路的地基握手不通后面全白搭。5. 验证请求与成功结果MCP Inspector 与本地脚本双路调试调试 MCP 最直观的工具是 MCP Inspector一条命令启动npx modelcontextprotocol/inspector node dist/index.js它会起一个本地 Web 界面默认地址在终端里会打印出来。打开后你能看到 Connect 按钮点一下就连上你的 stdio 服务端。左侧是工具列表应该能看到sum点进去填参数a5, b6点 Run右侧会显示返回的11。整个过程你能看到原始 JSON-RPC 消息这对理解协议帮助极大。如果你不想开浏览器也可以写个本地客户端脚本src/client.ts用 SDK 的 Client 类主动连import { Client } from modelcontextprotocol/sdk/client/index.js; import { StdioClientTransport } from modelcontextprotocol/sdk/client/stdio.js; const transport new StdioClientTransport({ command: node, args: [dist/index.js], }); const client new Client( { name: demo-client, version: 1.0.0 }, { capabilities: {} } ); await client.connect(transport); const tools await client.listTools(); console.log(可用工具:, JSON.stringify(tools, null, 2)); const result await client.callTool({ name: sum, arguments: { a: 5, b: 6 }, }); console.log(调用结果:, JSON.stringify(result, null, 2)); await client.close();用npx tsx src/client.ts跑你会看到工具列表和content: [{ type: text, text: 11 }]。实测下来客户端脚本比 Inspector 更适合放进 CI 做回归测试因为它是纯命令行、可断言输出。成功结果长这样{ content: [ { type: text, text: 11 } ] }到这里一个完整的 stdio JSON-RPC 工具链就跑通了客户端启动服务端进程、initialize 握手、tools/list 发现、tools/call 调用、返回 content。你可以把sum换成任何真实逻辑比如查数据库、调内部 API、读文件。6. 本篇常见错排查401、local proxy failed、reading choices 与 OAuth第一个高频错误是 401。如果你在客户端里配了模型接入但 Key 没生效会看到401 Unauthorized。排查顺序确认环境变量TAOTOKEN_API_KEY在当前 shell 里能echo出来确认客户端配置里引用的是这个变量而不是写死的旧 Key确认 Base URL 是 https://taotoken.net/api 而不是带路径的完整端点。Key 和 Base URL 任一不对都会 401。第二个是local proxy failed或连接被拒。stdio 场景下这通常不是网络问题而是你启动服务端的命令路径不对。检查客户端配置里的command和args比如node dist/index.js要求当前工作目录下有dist/index.js。用绝对路径最稳。另外如果服务端启动时往 stdout 打了日志客户端会解析失败并报连接异常把日志改到 stderr。第三个是reading choices相关报错一般出现在模型返回结构不符合预期时。如果你在工具里调了模型接口返回体里没有choices字段说明请求没打到正确的端点或者模型 ID 写错了。对照接入文档 https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content 检查请求体格式。第四个是 OAuth 相关报错。MCP 的 http 传输支持 OAuth 鉴权但 stdio 不需要。如果你在 stdio 服务端里看到 OAuth 报错多半是误引入了 http 传输的配置。stdio 场景下不要配auth字段直接连就行。还有一个隐蔽的坑ERR_REQUIRE_ESM。前面提过package.json的type和tsconfig的module必须匹配。如果你从 CommonJS 模板改过来记得把require全换成import并在package.json加type: module。排查通用思路先单独跑服务端用echo喂 initialize 请求确认协议层通再跑客户端脚本确认 SDK 层通最后接 AI 应用确认 Host 层通。分层定位比一上来就怀疑模型快得多。7. 语义一致 CTA从跑通到长期使用跑通第一个 MCP 工具后下一步通常是把它接到真实 AI 应用里。如果你用的是 Claude Code 或类似编码 Agent需要配 Base URL、Key、Model ID 三件套接入文档里有完整示例https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content 。Key 在 https://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content 管理建议按项目分 Key方便轮换和审计。想先验证模型连通性用模型对话页面最快https://taotoken.net/model-chat?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content 。如果你打算把 MCP 工具链用在日常编码或 Agent 工作流里Coding Plan 比按次调用更合适https://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content 。最后给一个实用建议把 MCP 服务端的工具注册和业务逻辑拆开。工具注册只负责 schema 和路由业务逻辑放单独模块这样你换传输方式stdio 换 http时不用动业务代码。我试过把审计类工具从 stdio 迁到 http因为逻辑层没耦合传输改的只有入口那几行。