最近不管是技术交流群还是信息流,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.js:StdioServerTransport,基于标准输入输出的本地传输。
- server/streamable-http.js:StreamableHTTPServerTransport,基于 HTTP 的远程传输。
- client/index.js:Client 类,写客户端测试脚本用。
- client/stdio.js:StdioClientTransport,客户端连接本地服务端时用。
如果你翻过 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 高频问题速查表
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 客户端连接不上 server | command 或 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 示例折腾得一头雾水,不妨退一步,先把协议边界画出来,再重新打开编辑器。