ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

MCP与TypeScript SDK实战:协议边界、选型与排错指南

MCP与TypeScript SDK实战:协议边界、选型与排错指南 最近不管是技术交流群还是信息流MCP 三个字出现的频率实在太高了。随手一刷就是 mcp server demo、figma mcp、claude code 安装 mcp 读取数据库连设计工具蓝湖、MasterGo 都开始推 MCP。作为一个在前后端工具链里泡了多年的开发者我身边已经有不少人直接被这股风带得打开 TypeScript 项目复制官方示例然后一脸懵地发现“示例能跑换到自己场景就废”。我一直想给这些人一个不太一样的建议先别急着跑示例花点时间把协议的边界看清楚。MCP 全称 Model Context Protocol是一套把 AI 应用和外部工具连接起来的开放协议本质上就是给模型一个标准化的“插线板”。这篇文章适合两类人一类是正准备用 TypeScript SDK 写第一个 MCP Server 的开发者另一类是已经在跑官方示例但遇到问题后不知道往哪个方向排查的工程师。我会结合官方 TypeScript SDK 的实际使用把协议边界、SDK 选型、最小可运行示例、排错经验这几块讲透尽量避开那些“复制就能跑跑完啥也没懂”的坑。1. 为什么我坚持把协议边界放在 SDK 前面很多教程恨不得让你 5 分钟跑起一个 demo这种引导本身没错但副作用是大家把 MCP 理解成了“一个帮你调工具的库”。实际上 MCP 是一套协议SDK 只是协议的一种实现。协议边界搞清楚之后你才会明白哪些问题是 SDK 的 bug、哪些问题是自己设计错了、哪些问题根本不该在这一层解决。1.1 MCP 到底解决什么问题先看最本质的定义。MCP 是 Model Context Protocol 的缩写它规定了一个模型上下文环境比如 Claude、Cursor、自研智能体如何发现并调用外部能力。在 MCP 出现之前每个 AI 应用接工具都是靠私有协议你给 A 写了个插件到 B 那边全部作废。MCP 的目标就是把这些能力接入抽成一套统一标准。这套标准里最基本的能力有三类工具Tools模型可以按需调用的函数比如查天气、写数据库、发 HTTP 请求。资源Resources可以被模型读取的数据或文件比如一份项目文档、一个配置项。提示Prompts预置的提示词模板用来引导模型在特定场景下输出。协议把这三类能力的定义、注册、发现、调用流程都规范了。注意关键词是“规范流程”不是“实现业务”。MCP 关心的是客户端怎么跟服务端说“我要调用工具”服务端怎么返回结果而不关心工具内部是去查 MySQL 还是调第三方 API。1.2 协议管什么不管什么用大白话讲MCP 的职责范围是一个“协议适配层”。它管消息格式、连接生命周期、能力协商、传输方式它不管业务逻辑、鉴权细节、执行策略、运行环境。举几个具体例子MCP 不负责“模型该怎么决定调用哪个工具”。这是模型自身能力和提示词设计的问题。工具描述写得稀烂模型就会乱调或者不调。MCP 不规定服务端必须用什么数据库、什么框架。你可以在本地进程里跑也可以在容器里跑甚至可以跑在云函数上。MCP 不内置权限系统。虽然协议里有 OAuth 相关的可选流程但它管的是“客户端有没有资格连上来”至于调用工具时能不能读某个文件、能不能删某条记录完全由服务端自己控制。MCP 不保证工具调用的幂等性也不默认帮你做超时重试。这些都是业务层或客户端自己要考虑的事情。这个边界清单非常重要。我见过好几个项目把鉴权、审计、限流全部塞进 MCP 服务端然后抱怨 MCP 难用。其实 MCP 更像一个“连接器”不是“业务网关”。理解这一点你就不会把协议当成万能框架去套。1.3 误判边界后的两种典型症状不看边界直接写代码通常会出现两种典型症状。第一种是“工具调不通”。服务端明明注册了工具模型和客户端也能看到工具列表但一调用就报参数错误或者超时。排到最后发现是 inputSchema 写得太随意字段缺类型、缺描述模型生成的参数根本对不上。这类问题不是协议 bug是你把“定义工具”理解成了“写个函数然后注册一下”没意识到工具描述本身是给模型看的接口契约。第二种是“示例能跑生产不能跑”。官方示例清一色是 stdio 传输本地跑挺顺一旦要部署到远程服务就发现连接建立不了、跨域不行、断线重连没有。原因很简单stdio 传输是为本地进程设计的服务器版本的 MCP 需要切换成 Streamable HTTP 传输而很多人根本没意识到传输层也是协议的一部分。所以我的建议一直是先花半小时把协议“管什么、不管什么”这张地图记住再打开编辑器写代码事半功倍。2. TypeScript 生态里的 SDK 选型协议搞清楚之后选 SDK 就有了判断依据。目前 TypeScript 生态里最主流的方案是官方提供的 modelcontextprotocol/sdk另外也有一些社区封装。很多人会纠结选哪个我的建议比较直接新项目优先官方 SDK。2.1 官方 SDK 的包结构与版本变迁官方 TypeScript SDK 的包名是 modelcontextprotocol/sdk。当前的 1.x 版本把接口划分得很清楚核心模块包括server/mcp.js提供 McpServer 类写服务端主要用它。server/stdio.jsStdioServerTransport基于标准输入输出的本地传输。server/streamable-http.jsStreamableHTTPServerTransport基于 HTTP 的远程传输。client/index.jsClient 类写客户端测试脚本用。client/stdio.jsStdioClientTransport客户端连接本地服务端时用。如果你翻过 0.x 版本的老代码会发现 API 变化很大。早期版本里服务端用的是 low-level 的 Server 类要手工处理 JSON-RPC 请求。现在 McpServer 把初始化握手、能力声明、工具注册这些事全封装了代码量少了很多。但也正因为封装程度高不少人忽略了协议层发生了什么。版本选择上务必用最新稳定版并且留意 SDK 版本和协议版本的对应关系。MCP 协议的版本号也在演进client 和 server 在初始化时会协商协议版本。如果两端支持的版本范围没有交集连接会直接失败。官方 SDK 通常会跟随最新协议版本但如果你引用了过旧的版本跟最新的客户端对接时就可能报 Unsupported protocol version。热词里“选项 baseurl 已弃用并将停止在 typescript 7.0 中运行”这种版本警告虽然说的是 TS 配置项但道理一样版本更新时旧行为会失效别拿旧教程硬套。2.2 官方方案和社区方案怎么权衡社区里也有几个 MCP 封装库有些封装声称“比官方更好用”比如自动生成 schema、简化资源注册等等。这类库确实能让 demo 写起来更快但我的态度是学习阶段先别碰。原因很简单。MCP 本身还在快速演进官方 SDK 是跟协议规范同步更新最及时的。社区封装为了易用性往往会隐藏协议细节导致你遇到问题的时候更难判断是协议问题还是封装问题。等你用官方 SDK 把协议机制摸透了再去看社区方案会发现它们其实也没做什么魔法只是在官方能力上套了一层糖。另外官方 SDK 的类型定义质量很高。协议里的消息结构、能力项、传输配置都有完整类型提示。写代码的时候类型系统会直接帮你挡住不少低级错误比如把工具返回结果的 content 字段形状写错。这是 TypeScript 相对其他语言一个很实际的优势。2.3 为什么我推荐先用 TypeScript 写 MCP除了官方 SDK 本身是 TypeScript 写的还有一个现实原因MCP 的最主流应用场景就是接入 Claude Code、Cursor 这类智能体工具而这些工具的生态大量使用 JS/TS。你写一个 MCP Server 出来最顺滑的验证路径就是配到这些客户端里。用 TypeScript 写跟客户端调试时的亲和度最高。当然前提是你对 TypeScript 的基础不陌生。热词里出现了大量“typescript 面试”“typescript 数组的方法”“typescript 和 js 的区别”这类搜索说明不少朋友是边补 TS 边学 MCP。这样也没问题但要有心理准备你遇到的第一个坑往往不是 MCP 协议而是 TS 工程配置比如 ESM 模块下 import 路径必须带 .js 后缀这种小事。3. 跑官方示例前必须搞懂的协议核心概念如果你已经确定了用官方 SDK下一步不是直接复制 README而是先建立几个关键概念。这几个概念是协议的核心骨架理解了它们官方示例在你眼里就不再是一堆魔法代码。3.1 Server、Client、Transport 三个角色的边界MCP 架构里最基本的三个角色Server服务端负责注册工具、资源和提示处理客户端的调用请求。Client客户端通常是 AI 应用本身负责连接服务端、发现能力、发起调用。Transport传输层承载 client 和 server 之间的消息交换是纯通道。这个三角色关系非常像 Web 开发里的前后端和 HTTP 协议。HTTP 本身不关心请求内容只负责传输MCP 里的 Transport 也一样。官方 SDK 里你要写一个 server核心工作是实现能力注册但 server 和外界怎么通信取决于你挂什么 Transport。这里有个特别容易踩的边界问题stdio 传输和 HTTP 传输对连接模型的要求完全不一样。stdio 模式是一个客户端进程拉起一个服务端进程一对一通信服务端进程的生命周期跟着客户端走。HTTP 模式是服务端常驻客户端通过网络连接一对多。很多人用 stdio 调试通过后直接把这个 server 原封不动部署到服务器结果发现客户端根本连不上就是因为没把传输层也换掉。协议管的是“连接之后怎么说话”不管“怎么建立连接”后者是传输层的事。3.2 Tools、Resources、Prompts 三种原语的取舍刚开始写 MCP最常见的问题就是“我到底该用工具还是资源”。这里我提供一个判断标准如果模型需要触发一个动作也就是会改变状态或产生副作用用工具。如果模型只需要读取一段数据用资源。如果模型在当前场景下应该按照某种特定方式输出用提示。打个比方工具像是给模型装了一双手能干活资源像是给模型开了一扇窗能看外面的信息提示像是给模型塞了一张纸条告诉它该怎么说。三者的边界不是绝对严格但设计时要有主次。比如读取天气数据如果只是展示资源就够了如果要根据天气提醒用户带伞那就得用工具因为你让模型做了判断和动作。实际项目中很多人喜欢把所有能力都包装成工具因为工具配 zod 校验很方便。但这样会把服务端的工具列表搞得非常臃肿模型每次都要从几十甚至上百个工具里挑选择准确率会下降。正确做法是能通过资源暴露的静态数据优先用资源需要模型决策后执行的动态操作才用工具。这个思想的本质还是在尊重协议边界。3.3 初始化握手与能力协商MCP 连接不是建立起来就能直接调工具它有个初始化握手过程。客户端先发一个 initialize 请求服务端返回自己支持的协议版本和能力声明然后客户端再发一个 initialized 通知表示确认。握手完成后双方才进入正常通信阶段。握手阶段最关键的是能力协商。服务端通过 capabilities 字段声明自己支持哪些原语比如 tools 支持多少、resources 支持多少、prompts 支持多少。客户端一样可以声明自己的能力比如是否支持 sampling采样。协商机制保证了双方在同一个能力边界里工作。这个机制带来的实际影响是如果你在服务端注册了 resources但忘记在 capabilities 里声明 resources 能力客户端初始化后是看不到这些资源的。官方 SDK 的 McpServer 通常会自动帮你声明但如果你在低层 Server 里手工处理消息就要自己维护这个对应关系。排查问题时第一件事永远是看握手阶段双方交换的 capabilities 和 protocolVersion。3.4 消息形状请求、响应、通知、错误MCP 的消息格式基于 JSON-RPC 2.0。虽然官方 SDK 把这层封装掉了但理解消息形状对排错非常关键。JSON-RPC 2.0 里只有四类消息请求request带 id对方必须响应。响应response带对应的 id携带结果。通知notification不带 id不需要响应。错误error响应的一种带错误码和消息。SDK 的高层封装把这些全部处理了你写工具 handler 时只需要返回一个 result 对象。但当你从工具调用失败、日志异常、连接中断等奇怪现象回推时最终都要落到“协议层到底交换了什么”这个问题上。比如某个操作没有返回响应你就得想是不是这条消息其实是 notification而不是 request。我建议大家至少用 Wireshark 的精神去对待 MCP 消息——不一定要抓包但在 MCP Inspector 里打开消息日志看一遍握手和工具调用过程中实际走的消息比看十篇教程都有用。4. 实操从零搭建一个最小 MCP Server概念铺垫够了接下来动手。下面这套操作我实测过很多次照着做能跑通一个带工具和资源的最小服务端并且用官方客户端和 MCP Inspector 双重验证。4.1 环境准备与工程初始化环境要求很简单Node.js 18 或更高版本建议用 20 LTS。npm 或 pnpm。一个你顺手的 TypeScript 工程。初始化项目mkdir mcp-demo cd mcp-demo npm init -y npm install modelcontextprotocol/sdk zod npm install -D typescript types/node tsx注意这里我安装了 zod因为官方 SDK 的工具输入校验默认推荐用 zod 定义 schema后面写代码会用到。tsx 是用来直接跑 TypeScript 的开发工具不想每次编译的话调试阶段很方便。创建 tsconfig.json{ compilerOptions: { target: ES2022, module: NodeNext, moduleResolution: NodeNext, outDir: ./dist, rootDir: ./src, strict: true, esModuleInterop: true, skipLibCheck: true }, include: [src/**/*] }然后把 package.json 里的 type 字段加上{ type: module }这个配置决定你写的是 ESM 模块所以后面所有相对导入路径都必须带 .js 后缀。这是 TypeScript 新手最容易卡住的地方报错往往是 “Cannot find module”。记住这是 NodeNext 模块解析的规则不是 bug。4.2 服务端完整代码与逐段讲解在 src/server.ts 里写一个最简 MCP Server包含一个工具和一个资源import { McpServer } from modelcontextprotocol/sdk/server/mcp.js; import { StdioServerTransport } from modelcontextprotocol/sdk/server/stdio.js; import { z } from zod; const server new McpServer({ name: demo-server, version: 1.0.0 }); server.registerTool( get_city_weather, { title: 获取城市天气, description: 根据城市名称返回模拟天气数据城市名用中文, inputSchema: { city: z.string().describe(城市名称) } }, async ({ city }) { const weather [晴, 多云, 小雨]; const random weather[Math.floor(Math.random() * weather.length)]; return { content: [{ type: text, text: ${city}${random}26℃ }] }; } ); server.registerResource( app-config, config://app, async (uri) ({ contents: [{ uri, text: JSON.stringify({ env: production, region: cn }) }] }) ); const transport new StdioServerTransport(); await server.connect(transport);代码拆开看McpServer 是 SDK 提供的高层服务端类。初始化时传入 name 和 version这两个字段会体现在握手阶段的 serverInfo 里。registerTool 的第一个参数是工具名客户端调用时用这个名字第二个参数里 title、description、inputSchema 共同构成工具的对外契约。模型主要靠 description 判断什么时候用这个工具所以描述一定要写清楚这是给模型读的不是给人读的。inputSchema 用 zod 的 z.string() 描述参数类型。SDK 内部会把 zod schema 转成 JSON Schema 并通过协议暴露给客户端。这里特别要注意z.object 之外的字段都需要 .describe()否则模型不知道每个参数是什么意思容易传错。工具 handler 返回结构必须是结构化内容数组text 类型是最常见的。这个结构是协议规定的不能随便写。registerResource 注册了一个静态资源URI 是 config://app。客户端读取时SDK 会调用这个回调返回 contents 数组。编译运行npx tsc node dist/server.js如果一切正常程序会挂住等待标准输入。这个挂住是正常的因为 stdio transport 在等客户端发消息。如果你在终端里手动跑输入任何内容都不会有响应因为协议消息是 JSON-RPC 格式不是普通文本。4.3 用 MCP Inspector 和最小客户端验证服务端写完怎么验证最推荐的是官方 MCP Inspector它能可视化连接你的 server浏览工具列表调用工具查看消息日志。启动方式npx modelcontextprotocol/inspector node dist/server.js打开浏览器进入 Inspector 界面先看 Tools 列表确认 get_city_weather 出现在列表里且 schema 正常。试着调用一次传一个 city 参数看返回结果。调用过程中把消息日志面板打开你会看到 initialize 请求、notifications/tools/list_changed、tools/call 等消息。这个日志面板是理解协议最好的老师。除了 Inspector也可以写一个极简客户端脚本验证import { Client } from modelcontextprotocol/sdk/client/index.js; import { StdioClientTransport } from modelcontextprotocol/sdk/client/stdio.js; const transport new StdioClientTransport({ command: node, args: [dist/server.js] }); const client new Client({ name: test-client, version: 1.0.0 }); await client.connect(transport); const tools await client.listTools(); console.log(工具列表, tools.tools.map(t t.name)); const result await client.callTool({ name: get_city_weather, arguments: { city: 杭州 } }); console.log(调用结果, result.content); await client.close();这个脚本通过 stdio 拉起服务端进程完成了一次完整的握手、工具发现和工具调用。跑通之后你对 MCP 的基本链路就有了直观认识。4.4 从示例到本地调试的注意事项示例跑通后有几个实操中的坑提前说一下都是我在真实环境踩过并帮别人排查过的。第一不要在服务端代码里用 console.log 打业务日志。stdio 传输模式下标准输出 stdout 是协议通道你往 stdout 写任何内容都会污染协议消息导致客户端解析失败。日志请写到 stderr或者用 SDK 提供的日志机制发送 log message notification。热词里“程序进入为什么会进入 disassembly 里面怎么退出 sdk”这类调试器问题也经常出现在 MCP 开发中如果你在 IDE 里以调试模式启动 stdio server调试器可能接管标准输入输出导致客户端连不上。排查时先把调试模式关掉或者确认 IDE 是否正确重定向了 stdio。第二工具函数内部要捕获自己的异常。示例代码为了简洁没有 try/catch但真实场景里工具调用的是数据库、第三方接口很容易抛错。SDK 支持在返回结果里标记 isError 字段你应该把错误信息以结构化内容返回而不是让异常直接抛到协议层。否则客户端看到的可能是一次没有响应的调用。第三进程退出清理。如果 server 里用到了数据库连接、定时器等资源在收到 SIGINT/SIGTERM 时要主动释放并调用 server.close()。stdio 模式下进程生命周期跟着客户端走客户端断开时服务端进程通常会被终止但网络传输模式下就全靠你自己管理连接生命周期了。5. 常见问题与排查技巧实录代码跑通之后更多的挑战来自实际使用。这里整理一个高频问题速查表基本都是我帮同事和朋友排查 MCP 问题时遇到的真实情况。5.1 高频问题速查表现象可能原因排查方向客户端连接不上 servercommand 或 args 配置错误确认可执行文件路径、node 版本、启动目录握手失败报协议版本不支持两端 SDK 版本差距过大统一升级 SDK检查 protocolVersion 协商结果工具列表能看到调用就失败inputSchema 定义不完整或描述缺失在 Inspector 里查看 schema补全参数类型和描述工具调用返回“超时”工具 handler 内部阻塞或抛错给 handler 加 try/catch检查是否有死循环或网络请求挂起服务端日志里出现乱码stdout 被业务日志污染把所有 console.log 改到 stderr或改用 SDK 日志机制HTTP 模式下连接闪断没有正确实现连接生命周期检查 StreamableHTTPServerTransport 的 session 管理处理断线重连资源列表为空capabilities 里没声明 resources或注册回调格式不对检查注册函数签名确认握手阶段 capabilities 是否包含 resources客户端工具列表缓存不刷新协议要求服务端发通知客户端才会重新拉取注册新工具后显式发送 tools/list_changed 通知表格里的问题有一个共同特征靠“看代码”看不出所以然必须靠“看消息”。协议是分层的定位问题要先确定是哪一层出的错。5.2 stdio 调试的独家经验stdio 传输的调试是整个 MCP 开发里最容易劝退新人的环节因为它不像 HTTP 那样有明确的请求日志。我这里分享三个自己常用的方法。第一个方法优先用 MCP Inspector。它的消息日志面板非常直观能看到客户端和服务端交换的所有 JSON-RPC 消息。我排查问题时第一件事永远是打开消息日志看初始化握手是否成功然后看一条业务调用链路的消息序列。这个习惯帮我省掉了至少一半的瞎猜时间。第二个方法在服务端代码里临时加 stderr 日志。因为 stdout 被协议占用但 stderr 是安全的。你可以在工具 handler 里写console.error(收到调用参数, JSON.stringify(args));然后用命令行手动启动服务端再用客户端连接。终端上能实时看到 stderr 输出但不会污染协议通道。第三个方法如果怀疑是“命令启动参数”的问题直接用命令行手动验证printf {jsonrpc:2.0,id:1,method:initialize,params:{protocolVersion:...,capabilities:{},clientInfo:{name:test,version:1.0.0}}}\n | node dist/server.js看到返回的 JSON 响应就说明 server 进程本身没问题问题大概率出在客户端的启动配置上。这个方法看起来笨但在网络环境复杂、没法打开 Inspector 的时候非常管用。5.3 生产化之前必须补的协议边界课从本地示例走向生产部署很多人以为就是换一台服务器的事。实际上协议边界之外的工程化问题比代码本身更复杂。首先是鉴权和权限控制。前面说过MCP 协议本身不管业务权限。生产环境里每个工具调用都需要做身份验证和授权判断。常见做法是在服务端内做一层统一的调用入口守卫检查来源客户端身份、调用者权限再分发到具体工具逻辑。这不是协议该做的事但你必须在协议外面补上。其次是超时和重试策略。SDK 对工具调用有默认超时但那是协议层的超时不等于业务超时。如果你的工具内部要调用一个可能耗时 30 秒的第三方 API而协议层超时只有 10 秒就会频繁报超时。解决办法是在工具 handler 内部实现自己的超时控制和异步任务调度协议层只管结果的返回。第三是幂等设计。模型可能会因为网络重试等原因对同一个工具发起多次调用。如果你的工具是“创建订单”“发送通知”这类有副作用的操作必须设计幂等机制比如使用请求 ID 去重。协议不会帮你做这件事但生产环境少了它就会出事故。这些内容已经超出了“MCP TypeScript SDK 推荐”的标题范围但恰恰是“先看协议边界”的实际价值只有知道协议不管什么你才知道自己要在协议外面补什么。结尾按照惯例最后不写总结了分享一点我个人的真实体会。我最初接触 MCP 的时候也是直接复制官方示例跑通一个天气工具还挺兴奋。但第一次换传输层、第一次接真实业务、第一次被模型反复调用同一个不幂等的工具时就发现光会跑示例远远不够。后来我花了几个晚上把协议文档从前往后翻了一遍重点盯住那些“协议不做什么”的章节再回头看 SDK 代码整个思路就清晰了——SDK 只是实现协议的工具真正的设计约束在协议规范里不在示例代码里。最后分享一个我一直在用的小技巧每次改完服务端代码不要急着在业务客户端里验证先用 MCP Inspector 走一遍握手和工具调用把消息日志打开看一遍。确认协议层没问题再去业务场景里测。这个习惯帮我避开了很多“客户端配置问题”和“服务端协议问题”混在一起的头痛场景。如果你现在正被 MCP 示例折腾得一头雾水不妨退一步先把协议边界画出来再重新打开编辑器。
返回列表