1. 为什么大模型需要一个「USB-C」口
第一次听到 MCP 这个词,是在一个做 AI 工具链的朋友群里。有人甩了张架构图,说 Anthropic 开源了一个叫 Model Context Protocol 的东西,想让所有大模型和外部工具之间用一套统一接口说话。当时我的第一反应是:又来了,又是一个「统一标准」。做开发这么多年,见过的「统一标准」没有一百也有八十,最后活下来的没几个。
但仔细看完协议设计之后,我改主意了。这个东西的思路确实不一样。
先说一个所有人都遇到过的场景。你手头有一个基于大模型的应用,想让它能读本地文件、能查数据库、能调内部 API。怎么做?写 function calling。OpenAI 有一套格式,Claude 有一套格式,通义千问又有一套。你每换一个模型,工具调用的代码就得重写一遍。更麻烦的是,如果你有五个工具、三个模型,那就是十五套适配逻辑。这不是开发,这是体力活。
MCP 要解决的就是这个问题。它把「模型怎么调用工具」这件事从模型侧抽离出来,定义成一套独立的协议。工具提供方只需要实现一个 MCP Server,任何支持 MCP 的客户端都能连上来用。模型换不换、换哪家,跟工具侧没关系。
打个比方。以前的局面像什么?像每个手机品牌都有自己的充电口,诺基亚是圆孔,索尼是专属接口,苹果是 Lightning。你换手机就得换一堆线。USB-C 出来之后,一根线走天下。MCP 想做的,就是大模型工具生态里的那个 USB-C。
这个类比不是我编的,社区里很多人就是这么理解的。你去看 MCP 的协议设计,确实处处体现着这种「接口标准化」的思路:传输层用 JSON-RPC 2.0,能力协商用 initialize 握手,工具描述用 JSON Schema。每一层都是成熟方案,没有发明新轮子。
那它到底适合谁?我的判断是三类人:一是正在做 AI Agent 的开发者,你迟早要面对工具集成的问题;二是做企业内部工具平台的团队,MCP 可以让你的工具一次开发、多端复用;三是对 AI 工程化感兴趣的后端工程师,这个协议的设计思路本身就值得学习。
下面我会从协议设计、核心概念、TypeScript 实战、踩坑经验几个维度,把 MCP 拆开讲清楚。不是翻译官方文档,是我自己跑通之后的理解。
2. MCP 协议的核心设计拆解
2.1 三个角色:Host、Client、Server
MCP 的架构里只有三个角色,这个划分非常干净。
Host是宿主应用,也就是用户直接交互的那个程序。比如 Claude Desktop、Cursor、或者你自己写的一个聊天界面。Host 负责管理多个 Client,每个 Client 对应一个 Server 连接。
Client是连接器,负责和 Server 建立一对一连接,处理协议层的消息收发。你可以把它理解成一个「协议翻译官」,Host 想调工具,Client 负责把请求翻译成 MCP 格式发出去,再把结果翻译回来。
Server是能力提供方。它暴露三类能力:Tools(可调用的函数)、Resources(可读取的数据)、Prompts(预定义的提示模板)。一个 Server 可以只提供其中一类,也可以三类都提供。
为什么要拆成三个角色而不是两个?我一开始也觉得多此一举。后来自己写了一个 Host 同时连三个 Server 的场景,才明白这个设计的价值。Client 层做了隔离,每个 Server 的连接状态、错误处理、重连逻辑都是独立的。如果只有 Host 和 Server 两层,这些逻辑全得塞在 Host 里,代码会变得非常难维护。
2.2 传输层:stdio 和 SSE 两条路
MCP 目前支持两种传输方式。
stdio是最常用的。Server 作为一个子进程启动,通过标准输入输出和 Client 通信。这种方式的好处是简单、无网络依赖、进程生命周期好管理。你本地跑一个文件系统 Server,用 stdio 就足够了。
SSE(Server-Sent Events)用于远程场景。Server 跑在一个 HTTP 服务上,Client 通过 SSE 接收服务端推送,通过 HTTP POST 发送请求。这种方式适合团队共享的工具服务,比如一个公司内部的知识库 Server,所有人连同一个地址。
选哪种?我的经验是:本地工具用 stdio,团队共享用 SSE。不要一上来就搞远程部署,stdio 调试起来方便太多。你可以直接在终端里看到 Server 的日志输出,出了问题一眼就能定位。
2.3 消息格式:JSON-RPC 2.0 的务实选择
MCP 的所有消息都是 JSON-RPC 2.0 格式。请求长这样:
{ "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "read_file", "arguments": { "path": "/tmp/test.txt" } } }响应:
{ "jsonrpc": "2.0", "id": 1, "result": { "content": [ { "type": "text", "text": "文件内容..." } ] } }为什么选 JSON-RPC 而不是 REST 或者 gRPC?我的理解是:JSON-RPC 足够简单,人类可读,调试方便;同时它天然支持双向通信,Server 也可以主动向 Client 发请求(比如采样请求)。REST 是单向的,gRPC 又太重,需要 proto 编译。JSON-RPC 刚好卡在中间那个甜点位上。
2.4 能力协商:initialize 握手
连接建立后,第一件事是 initialize 握手。Client 发送自己支持的能力,Server 回复自己提供的能力。这个过程决定了后续能用哪些功能。
// Client 发送 { method: "initialize", params: { protocolVersion: "2024-11-05", capabilities: { roots: { listChanged: true }, sampling: {} }, clientInfo: { name: "my-client", version: "1.0.0" } } }Server 回复:
{ result: { protocolVersion: "2024-11-05", capabilities: { tools: { listChanged: true }, resources: { subscribe: true, listChanged: true } }, serverInfo: { name: "my-server", version: "1.0.0" } } }这个握手的意义在于版本兼容。如果 Client 和 Server 支持的协议版本不一致,可以在这里发现并处理。capabilities 字段则告诉对方「我能做什么」,避免调用不支持的功能。
注意:protocolVersion 目前是日期格式的字符串,不是语义化版本号。这个设计有点反直觉,我第一次看到的时候以为是笔误。后来想想也有道理,协议这种东西按日期标记更清晰,避免出现「1.2.3 到底比 1.2.4 新还是旧」的争论。
3. TypeScript 实战:从零写一个 MCP Server
3.1 环境准备与依赖安装
先建项目。我用的是 Node.js 20 + TypeScript 5.4,这是目前比较稳的组合。
mkdir mcp-demo-server cd mcp-demo-server npm init -y npm install @modelcontextprotocol/sdk zod npm install -D typescript @types/node tsx@modelcontextprotocol/sdk是官方 SDK,封装了协议层的细节。zod用来定义工具参数的 schema,SDK 会自动把它转成 JSON Schema。
配置 tsconfig.json:
{ "compilerOptions": { "target": "ES2022", "module": "Node16", "moduleResolution": "Node16", "outDir": "./dist", "rootDir": "./src", "strict": true, "esModuleInterop": true, "skipLibCheck": true }, "include": ["src/**/*"] }这里有个坑:module必须设成Node16或NodeNext,否则 SDK 里的 ESM 导入会报错。我一开始用CommonJS,折腾了半小时才发现问题。
3.2 写一个最小的 Server
先写一个能跑起来的最小版本,只提供一个工具:计算两个数的和。
// src/index.ts 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: "demo-server", version: "1.0.0", }, { capabilities: { tools: {}, }, } ); // 定义工具的参数 schema const AddArgsSchema = z.object({ a: z.number().describe("第一个加数"), b: z.number().describe("第二个加数"), }); // 注册工具列表 server.setRequestHandler(ListToolsRequestSchema, async () => { return { tools: [ { name: "add", description: "计算两个数字的和", inputSchema: { type: "object", properties: { a: { type: "number", description: "第一个加数" }, b: { type: "number", description: "第二个加数" }, }, required: ["a", "b"], }, }, ], }; }); // 处理工具调用 server.setRequestHandler(CallToolRequestSchema, async (request) => { if (request.params.name === "add") { const args = AddArgsSchema.parse(request.params.arguments); const result = args.a + args.b; return { content: [ { type: "text", text: `结果是 ${result}`, }, ], }; } throw new Error(`未知工具: ${request.params.name}`); }); // 启动 const transport = new StdioServerTransport(); await server.connect(transport);跑起来:
npx tsx src/index.ts如果没报错,说明 Server 已经启动,正在等待 stdin 输入。你可以手动发一条 JSON-RPC 消息测试:
echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}' | npx tsx src/index.ts应该能看到 initialize 的响应。
3.3 工具参数校验:Zod 与 JSON Schema 的配合
上面代码里有个细节值得展开。我定义了两套 schema:一套是 Zod 的AddArgsSchema,一套是手写的 JSON Schema。为什么要写两遍?
其实 SDK 提供了zodToJsonSchema工具,可以自动转换。但我在实际项目里还是倾向于手写 JSON Schema,原因是:JSON Schema 里的description字段会直接暴露给模型,影响模型对参数的理解。手写可以更精细地控制措辞,让模型更容易正确调用。
比如a这个参数,如果只写z.number(),转出来的 description 是空的。模型看到「a」这个名字,可能不知道是什么意思。加上.describe("第一个加数")之后,模型就能理解这个参数的含义。
实操心得:工具描述和参数描述是模型能否正确调用的关键。我踩过的坑是,工具名用了缩写比如
calc,模型经常不调用,改成calculate_sum之后调用率明显提升。描述要写清楚「这个工具做什么、什么时候用、参数是什么含义」,不要偷懒。
3.4 返回富文本内容
MCP 的 content 字段支持多种类型,不只是纯文本。
return { content: [ { type: "text", text: "操作成功", }, { type: "image", data: base64ImageData, mimeType: "image/png", }, { type: "resource", resource: { uri: "file:///tmp/result.json", mimeType: "application/json", text: JSON.stringify(data), }, }, ], };这个设计很实用。比如你写一个截图工具,可以直接返回 image 类型,客户端会渲染成图片。写一个文件读取工具,可以返回 resource 类型,客户端知道这是一个资源引用。
3.5 错误处理:isError 标记
工具执行失败时,不要直接抛异常,而是返回带isError: true的结果:
try { const result = await doSomething(); return { content: [{ type: "text", text: result }] }; } catch (error) { return { content: [ { type: "text", text: `执行失败: ${error.message}`, }, ], isError: true, }; }为什么?因为抛异常会导致整个 JSON-RPC 请求失败,Client 收到的是协议层错误,模型看不到具体的错误信息。而返回isError: true的话,错误信息会作为工具结果传给模型,模型可以根据错误内容决定下一步怎么做,比如换个参数重试。
这个区别在实际使用中影响很大。我做过对比测试,同样的工具,用抛异常的方式,模型遇到错误就卡住了;用 isError 的方式,模型会尝试修正参数,成功率明显更高。
4. 进阶实战:接入真实场景
4.1 写一个文件系统 Server
光算加法没意思,来写个实用的。下面是一个只读文件系统 Server,提供两个工具:列目录、读文件。
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"; import * as fs from "node:fs/promises"; import * as path from "node:path"; const ALLOWED_ROOT = "/tmp/mcp-workspace"; const server = new Server( { name: "fs-server", version: "1.0.0" }, { capabilities: { tools: {} } } ); const ListArgsSchema = z.object({ dir: z.string().default("."), }); const ReadArgsSchema = z.object({ file: z.string(), }); server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: [ { name: "list_directory", description: "列出指定目录下的文件和子目录。只能访问工作区内的路径。", inputSchema: { type: "object", properties: { dir: { type: "string", description: "相对于工作区根目录的路径,默认为当前目录", }, }, }, }, { name: "read_file", description: "读取指定文件的文本内容。只能读取工作区内的文件。", inputSchema: { type: "object", properties: { file: { type: "string", description: "相对于工作区根目录的文件路径", }, }, required: ["file"], }, }, ], })); function safePath(relative: string): string { const resolved = path.resolve(ALLOWED_ROOT, relative); if (!resolved.startsWith(ALLOWED_ROOT)) { throw new Error("路径越界,拒绝访问"); } return resolved; } server.setRequestHandler(CallToolRequestSchema, async (request) => { try { if (request.params.name === "list_directory") { const args = ListArgsSchema.parse(request.params.arguments); const target = safePath(args.dir); const entries = await fs.readdir(target, { withFileTypes: true }); const lines = entries.map((e) => e.isDirectory() ? `[DIR] ${e.name}` : `[FILE] ${e.name}` ); return { content: [{ type: "text", text: lines.join("\n") || "(空目录)" }], }; } if (request.params.name === "read_file") { const args = ReadArgsSchema.parse(request.params.arguments); const target = safePath(args.file); const content = await fs.readFile(target, "utf-8"); return { content: [{ type: "text", text: content }], }; } throw new Error(`未知工具: ${request.params.name}`); } catch (error) { return { content: [{ type: "text", text: `错误: ${(error as Error).message}` }], isError: true, }; } }); const transport = new StdioServerTransport(); await server.connect(transport);这个 Server 有两个关键设计点。
第一是路径安全检查。safePath函数确保所有访问都在ALLOWED_ROOT之内。如果不做这个检查,模型可能被诱导去读/etc/passwd之类的敏感文件。这是安全底线,不能省。
第二是默认参数。list_directory的dir参数有默认值.,模型不传这个参数也能工作。这种设计对模型友好,减少调用失败的概率。
4.2 在 Claude Desktop 里配置
Server 写好了,怎么让 Claude Desktop 用上?编辑配置文件:
- macOS:
~/Library/Application Support/Claude/claude_desktop_config.json - Windows:
%APPDATA%\Claude\claude_desktop_config.json
{ "mcpServers": { "fs-server": { "command": "npx", "args": ["tsx", "/absolute/path/to/src/index.ts"] } } }重启 Claude Desktop,如果配置正确,输入框旁边会出现一个工具图标,点开能看到list_directory和read_file。
注意:路径必须用绝对路径。我一开始用了相对路径,Claude Desktop 启动 Server 时工作目录不对,一直报找不到文件。改成绝对路径就好了。
4.3 调试技巧:用 MCP Inspector
官方提供了一个调试工具叫 MCP Inspector,可以可视化地查看 Server 提供的工具、手动调用、看请求响应。
npx @modelcontextprotocol/inspector npx tsx src/index.ts它会启动一个本地 Web 界面,默认在http://localhost:5173。左边是工具列表,右边是调用面板。调工具的时候能看到完整的 JSON-RPC 消息,排查问题非常方便。
我现在的开发流程是:先用 Inspector 把工具调通,确认参数和返回值都正确,再去配置到 Claude Desktop 里。这样能把问题隔离在 Server 侧,不用每次都重启客户端。
5. 常见问题与排查实录
5.1 连接类问题
问题:Claude Desktop 里看不到工具图标。
排查顺序:先确认配置文件路径对不对,JSON 格式有没有语法错误(逗号、引号最容易出问题)。然后看command和args能不能在终端里直接跑通。最后看 Claude Desktop 的日志,macOS 在~/Library/Logs/Claude/mcp.log。
问题:Server 启动后立即退出。
大概率是代码里有未捕获的异常。stdio 模式下,Server 的 stderr 会被客户端捕获,但 stdout 是协议通道,不能随便打印东西。如果你在代码里用了console.log,会污染协议消息,导致解析失败。调试信息一律用console.error。
5.2 工具调用类问题
问题:模型不调用工具,直接回答。
三个可能原因:工具描述不够清晰、参数 schema 有问题、或者模型本身的能力限制。先检查 description 字段,确保写清楚了「什么时候该用这个工具」。然后检查 required 字段,如果参数都是可选的,模型可能觉得不传也行。
问题:模型传的参数类型不对。
比如 schema 里定义的是 number,模型传了字符串 "123"。这种情况在 Zod 的parse里会抛错。解决办法是用z.coerce.number()做类型转换,或者在 description 里明确写「必须是数字类型」。
问题:工具返回的内容太长,超出上下文。
大模型有上下文长度限制,返回几万字的文件内容会直接把上下文撑爆。解决办法是在 Server 侧做截断,比如只返回前 2000 个字符,并提示「内容已截断,如需完整内容请指定范围」。更好的做法是提供分页参数,让模型自己控制读取范围。
5.3 常见问题速查表
| 现象 | 可能原因 | 解决方法 |
|---|---|---|
| 客户端看不到工具 | 配置路径错误 | 检查配置文件路径和 JSON 格式 |
| Server 启动即退出 | 代码异常或 stdout 污染 | 用 console.error 输出日志 |
| 模型不调用工具 | 描述不清晰 | 优化 description,明确使用场景 |
| 参数类型错误 | 模型传了错误类型 | 用 z.coerce 做类型转换 |
| 返回内容过长 | 未做截断 | Server 侧限制返回长度 |
| 路径访问被拒 | 安全检查触发 | 确认访问路径在工作区内 |
| 中文乱码 | 编码问题 | 确保读写都用 utf-8 |
5.4 几个我踩过的坑
坑一:忘记处理 initialize 之后的 initialized 通知。MCP 协议规定,Client 收到 initialize 响应后,要发送一个notifications/initialized通知。SDK 会自动处理这个,但如果你自己实现协议层,漏掉这一步会导致后续请求被拒绝。
坑二:工具名用了中文。虽然协议没明确禁止,但实际测试下来,中文工具名在某些客户端里会出问题。建议统一用英文小写加下划线。
坑三:在 Server 里做了耗时操作。比如一个工具要跑 30 秒,Client 那边可能已经超时了。解决办法是把耗时操作拆成「提交任务」和「查询结果」两个工具,或者用 MCP 的 progress 通知机制上报进度。
坑四:忽略了并发请求。如果 Client 同时发多个工具调用请求,Server 要能正确处理。SDK 默认是并发处理的,但如果你在工具实现里用了共享状态,要注意加锁。
6. 我对 MCP 的一些判断
写到这里,协议本身的东西基本讲完了。最后说几点我自己的看法,不一定对,供参考。
MCP 最大的价值不在于技术有多先进,而在于它把「工具集成」这件事标准化了。以前每个 AI 应用都要自己造轮子,现在有了统一接口,工具可以复用,生态可以积累。这个思路是对的。
但它也不是银弹。我实际用下来,有几个地方还不够成熟。一是协议版本还在快速迭代,不同版本的兼容性需要关注。二是远程传输的安全性方案还在完善中,企业级部署要额外做很多工作。三是工具的描述和发现机制还比较原始,工具多了之后,模型怎么从几十个工具里选对那个,是个问题。
不过这些都是工程问题,不是方向问题。方向对了,剩下的就是时间。
如果你现在正在做 AI Agent 相关的东西,我的建议是:先花半天时间,照着上面的例子跑通一个最小 Server,感受一下整个流程。然后把你手头最常用的一个内部工具包装成 MCP Server,接到 Claude Desktop 或者 Cursor 里试试。跑通之后,你自然就知道这个东西对你的项目有没有用了。
代码都在上面,直接抄就行。遇到问题先看日志,stdio 模式下 stderr 是你的朋友。