1. 从一根线缆的痛点到 MCP 的诞生逻辑
如果你最近在折腾大模型应用开发,大概率已经被一个词反复刷屏:MCP。全称 Model Context Protocol,翻译过来叫“模型上下文协议”。名字听着挺唬人,但你可以先把它理解成一根“USB-C 线”——一头插在大模型上,另一头插在外部工具、数据源或者服务上,插上就能用,不用每次都为不同的设备重新焊一根专用线。
我最早接触大模型工具调用是在做企业内部知识库问答的时候。当时为了让模型能查数据库、读本地文件、调内部 API,每个功能都得单独写一套适配层:OpenAI 的 function calling 一套格式,Claude 的 tool use 又是另一套结构,本地跑的模型还得再包一层。项目里光“工具描述转换”的胶水代码就占了将近三分之一,维护起来极其痛苦。后来 MCP 出现,我第一次读完它的设计文档,感觉就是:终于有人把这件事标准化了。
MCP 要解决的核心问题其实很朴素:大模型和外部世界之间的连接方式太碎片化了。在没有统一协议之前,每接一个新工具,你都要重新定义“模型怎么知道有这个工具”“模型怎么调用它”“调用结果怎么回传给模型”这三件事。MCP 把这三件事抽象成了标准化的客户端-服务端交互模型,让工具提供方和模型使用方可以各自独立演进。
这篇文章适合谁看?如果你是大模型应用开发者,正在为工具集成头疼;或者你是后端工程师,想把现有服务暴露给 AI 使用;又或者你只是好奇“MCP 到底是什么、跟我有什么关系”,这篇内容都能给你一条从概念到跑通代码的完整路径。我会用 TypeScript 写一个可运行的 MCP 服务端和客户端示例,把协议的核心机制拆开讲清楚,再分享几个我在实际接入中踩过的坑。
提示:本文假设你有基本的 TypeScript 和 Node.js 开发经验,了解大模型 API 的基本调用方式。如果你完全没接触过工具调用,建议先了解一下 function calling 的基本概念再往下看。
2. MCP 协议的核心机制拆解
2.1 为什么是“协议”而不是“框架”
很多人第一次听到 MCP 会下意识觉得它是个 SDK 或者框架,其实不是。MCP 定义的是通信协议,就像 HTTP 定义了浏览器和服务器怎么对话一样,它定义了 AI 应用和外部能力之间怎么对话。这个定位非常关键,因为协议意味着语言无关、实现无关。
我见过不少团队一开始把 MCP 当成某个特定库来用,结果发现不同语言、不同厂商都有自己的 MCP 实现,就懵了。实际上 MCP 的规范是公开的,你可以用 TypeScript 写服务端,用 Python 写客户端,只要双方都遵循协议的消息格式,就能互通。这一点和 USB-C 的逻辑完全一致:不管你是什么品牌的设备,接口对了就能插。
协议的核心参与者有三个角色:
- Host(宿主):运行大模型的应用程序,比如一个聊天客户端、一个 IDE 插件、一个自动化工作流引擎。Host 负责管理多个 Client。
- Client(客户端):由 Host 创建,负责与单个 Server 建立一对一连接,处理协议消息的收发。
- Server(服务端):提供具体能力的一方,比如文件系统访问、数据库查询、第三方 API 封装。每个 Server 通过标准接口暴露自己的工具、资源和提示模板。
这种三角结构的好处是职责清晰。Host 不需要知道每个工具的具体实现细节,Client 负责协议层的翻译,Server 只管把自己的能力用标准格式描述出来。
2.2 三种核心原语:Tools、Resources、Prompts
MCP Server 能暴露的能力被归纳为三种原语,这个分类是理解整个协议的关键。
Tools(工具)是最常用的一种。它代表模型可以主动调用的函数,比如“查询天气”“发送邮件”“执行 SQL”。每个 Tool 都有名字、描述和输入参数的 JSON Schema。模型根据这些信息决定要不要调用、怎么传参。这跟 OpenAI 的 function calling 在概念上很像,但 MCP 把它标准化了,不绑定任何特定模型厂商。
Resources(资源)代表可以被读取的数据,比如文件内容、数据库记录、API 返回的 JSON。和 Tools 的区别在于,Resources 通常是被动读取的,更像是给模型提供上下文素材。你可以把 Resources 理解成“模型可以翻阅的资料库”,而 Tools 是“模型可以执行的动作”。
Prompts(提示模板)是预定义的提示词模板,允许 Server 向 Host 提供结构化的对话起点。这个原语用得相对少一些,但在一些需要标准化交互流程的场景下很有用,比如“代码审查模板”“周报生成模板”。
我个人的经验是,刚开始只需要重点关注 Tools,把工具调用跑通,Resources 和 Prompts 可以后续按需引入。很多实际项目里,Tools 一个原语就够用了。
2.3 传输层:stdio 与 SSE 的取舍
MCP 目前支持两种主要的传输方式:stdio(标准输入输出)和SSE(Server-Sent Events)。这个选择直接影响你的部署架构,值得单独拿出来说。
stdio 模式下,MCP Server 作为一个子进程被 Host 启动,双方通过标准输入输出流交换消息。这种方式的优点是简单、无需网络配置、天然隔离。缺点是 Server 必须和 Host 在同一台机器上,无法跨网络调用。适合本地工具类场景,比如文件操作、本地数据库访问。
SSE 模式下,MCP Server 作为一个独立的 HTTP 服务运行,通过 Server-Sent Events 推送消息。这种方式支持远程调用,适合把工具部署在服务器上供多个客户端使用。但需要处理网络、认证、连接管理等额外问题。
我实测下来的建议是:本地开发和个人工具用 stdio,团队共享和生产部署用 SSE。stdio 的调试体验好很多,日志直接打在终端里,出问题一眼就能看到。SSE 虽然灵活,但连接断开、消息乱序这些问题在弱网环境下会让人很头疼。
| 对比维度 | stdio | SSE |
|---|---|---|
| 部署位置 | 必须同机 | 可跨网络 |
| 配置复杂度 | 低 | 中高 |
| 调试便利性 | 高 | 中 |
| 适用场景 | 本地工具、个人使用 | 团队共享、生产服务 |
| 连接管理 | 进程生命周期 | 需手动处理重连 |
3. 用 TypeScript 从零搭一个 MCP Server
3.1 环境准备与依赖选择
动手之前先把环境理清楚。我用的方案是官方提供的 TypeScript SDK,包名是@modelcontextprotocol/sdk。Node.js 版本建议 18 以上,因为 SDK 里用到了较新的流处理 API。
初始化项目没什么特别的:
mkdir mcp-demo-server cd mcp-demo-server npm init -y npm install @modelcontextprotocol/sdk zod npm install -D typescript @types/node tsx这里重点说一下zod这个依赖。MCP 的工具参数校验和 JSON Schema 生成都依赖它,SDK 内部用 zod 来定义工具输入的结构。你不需要单独写 JSON Schema,用 zod 定义好类型,SDK 会自动转换成协议需要的格式。这个设计省了很多事,也是我推荐用官方 SDK 而不是手写协议消息的主要原因。
tsconfig.json的配置需要注意模块系统。SDK 目前对 ESM 支持更好,建议这样配:
{ "compilerOptions": { "target": "ES2022", "module": "Node16", "moduleResolution": "Node16", "outDir": "./dist", "strict": true, "esModuleInterop": true } }注意:如果你用 CommonJS 模式,可能会遇到 SDK 内部 ESM 模块加载失败的问题。我一开始图省事用了默认配置,结果报了一堆
ERR_REQUIRE_ESM,折腾了半小时才定位到是模块系统不匹配。
3.2 定义第一个 Tool:从 zod schema 到协议消息
假设我们要做一个“查询本地文件信息”的工具。先定义输入参数的 schema:
import { z } from "zod"; const FileInfoInput = z.object({ path: z.string().describe("要查询的文件绝对路径"), includeContent: z.boolean().optional().describe("是否返回文件内容,默认 false"), });这里有个细节值得展开:.describe()里的文字会直接进入工具描述,模型就是靠这段文字理解参数含义的。描述写得越清楚,模型调用越准确。我踩过的坑是早期描述写得太简略,比如只写“路径”,结果模型经常传相对路径或者传错格式。后来改成“要查询的文件绝对路径”,准确率明显提升。
接下来创建 Server 实例并注册工具:
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { stat, readFile } from "fs/promises"; const server = new McpServer({ name: "file-info-server", version: "1.0.0", }); server.tool( "get_file_info", "获取指定路径文件的元信息,可选返回文件内容", FileInfoInput.shape, async ({ path, includeContent }) => { const stats = await stat(path); const result: Record<string, unknown> = { size: stats.size, isDirectory: stats.isDirectory(), modifiedAt: stats.mtime.toISOString(), }; if (includeContent && !stats.isDirectory()) { result.content = await readFile(path, "utf-8"); } return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }], }; } );注意server.tool()的第三个参数传的是FileInfoInput.shape而不是整个 zod 对象。这个细节官方文档里写得不太显眼,我一开始传错了导致 schema 生成异常。.shape取出的是字段定义,SDK 需要的是这个。
返回值必须是content数组,每个元素有type字段。目前最常用的是text类型,直接返回字符串。如果你要返回图片或二进制数据,可以用image类型,传 base64 编码。
3.3 启动 stdio 传输并接入客户端
Server 定义好了,接下来启动传输层:
async function main() { const transport = new StdioServerTransport(); await server.connect(transport); console.error("MCP Server 已启动,等待连接..."); } main().catch((err) => { console.error("启动失败:", err); process.exit(1); });这里有个容易忽略的点:日志必须打到 stderr,不能打到 stdout。因为 stdio 模式下 stdout 是协议消息通道,你往里面写任何非协议内容都会导致客户端解析失败。我见过有人用console.log打调试信息,结果客户端一直报“无效消息格式”,排查半天才发现是日志污染了通道。
启动命令配置在package.json里:
{ "scripts": { "start": "tsx src/server.ts" } }客户端这边,用 SDK 提供的Client类连接:
import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; const transport = new StdioClientTransport({ command: "npx", args: ["tsx", "src/server.ts"], }); const client = new Client({ name: "demo-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_file_info", arguments: { path: "/etc/hosts", includeContent: true }, }); console.log("调用结果:", result.content);跑通这段代码,你就完成了一个最小可用的 MCP 工具调用链路。整个过程不需要任何大模型参与,纯粹是协议层的验证。我强烈建议先把这一步跑通再去接模型,否则出了问题你分不清是协议层的问题还是模型调用的问题。
4. 把 MCP 接入大模型:完整链路与实测细节
4.1 模型如何“看到”工具列表
协议层跑通之后,下一步是让大模型真正用起来。这里的核心机制是:Host 把 MCP Server 暴露的工具列表转换成模型能理解的格式,塞进对话上下文。
以常见的对话补全接口为例,工具列表会被转换成类似这样的结构:
{ "tools": [ { "type": "function", "function": { "name": "get_file_info", "description": "获取指定路径文件的元信息,可选返回文件内容", "parameters": { "type": "object", "properties": { "path": { "type": "string", "description": "要查询的文件绝对路径" }, "includeContent": { "type": "boolean", "description": "是否返回文件内容,默认 false" } }, "required": ["path"] } } } ] }MCP 的 Tool 定义和这个格式之间的转换是机械的,SDK 已经帮你做好了大部分工作。你需要注意的是工具数量。我实测发现,当工具数量超过 20 个左右时,模型的选择准确率会明显下降,而且每次请求的 token 消耗也会增加不少。所以生产环境里,建议按场景拆分多个 MCP Server,而不是把所有工具塞进一个。
4.2 一次完整的工具调用往返
让我把一次完整的调用链路拆开,这样你能清楚每个环节发生了什么。
第一步,用户提问:“帮我看看 /var/log/app.log 这个文件多大,最近改过没有。”
第二步,Host 把用户消息和工具列表一起发给模型。模型判断需要调用get_file_info,返回一个工具调用请求,包含工具名和参数。
第三步,Host 收到工具调用请求后,通过 MCP Client 转发给 Server。Server 执行实际的文件查询逻辑,返回结果。
第四步,Host 把工具执行结果作为一条新消息追加到对话历史里,再次发给模型。模型根据结果生成自然语言回复:“这个文件大小是 2.3MB,最后修改时间是今天下午 3 点 42 分。”
这个往返过程里,第三步到第四步之间的错误处理最容易被忽略。如果工具执行失败,比如文件不存在,你不能直接把异常抛出去,而是要把错误信息作为工具结果返回给模型,让模型自己决定怎么跟用户解释。我一开始没注意这点,工具报错直接导致整个对话中断,体验很差。
server.tool( "get_file_info", "获取指定路径文件的元信息", FileInfoInput.shape, async ({ path, includeContent }) => { try { const stats = await stat(path); // ... 正常逻辑 return { content: [{ type: "text", text: JSON.stringify(result) }] }; } catch (err) { return { content: [{ type: "text", text: `读取失败: ${(err as Error).message}` }], isError: true, }; } } );注意isError: true这个标记,它告诉 Host 这次工具调用是失败的,但消息本身是合法的。模型收到之后会用自然语言解释失败原因,而不是让整个流程崩掉。
4.3 多工具协作时的上下文管理
当你有多个工具需要配合完成一个任务时,上下文管理就变得重要了。举个实际例子:用户说“把昨天的日志文件压缩一下发给我”。这个任务需要先列出日志目录、找到昨天的文件、执行压缩、然后返回结果。
模型会依次调用多个工具,每次调用结果都会追加到上下文里。这里的问题是,工具返回的内容如果太大,会迅速吃满上下文窗口。我遇到过一次,工具返回了一个 50KB 的日志内容,直接把上下文撑爆了,后续对话全部失败。
解决办法有两个:一是在工具实现里做截断,超过一定长度只返回摘要和前后几行;二是在 Host 层做上下文压缩,把历史工具结果替换成简短摘要。我一般两个都做,工具层截断保证单次返回可控,Host 层压缩保证长对话不爆。
提示:工具返回内容建议控制在 4KB 以内,超过的部分用“内容过长,已截断,完整内容共 X 行”这样的提示代替。模型通常不需要完整内容就能做出判断。
5. 实际接入中踩过的坑与排查思路
5.1 工具描述写不好,模型就是不用
这是最常见也最隐蔽的问题。工具注册了,协议也通了,但模型就是不调用,或者调用了但参数传错。排查下来,九成以上是工具描述的问题。
我总结了几条描述编写的经验:
- 工具名用动词开头,比如
get_file_info、send_email、query_database,不要用fileInfo这种名词形式。 - 描述里写清楚“什么时候用”,而不只是“是什么”。比如“当用户询问文件大小、修改时间等元信息时使用”,比“获取文件信息”有效得多。
- 参数描述要具体到格式。如果参数是日期,写明“格式为 YYYY-MM-DD”;如果是路径,写明“绝对路径,以 / 开头”。
- 避免歧义。如果有两个工具功能相近,在描述里明确区分它们的适用场景。
我做过一个对比测试:同一组工具,描述优化前后,模型调用准确率从 62% 提升到了 89%。这个投入产出比非常高,值得花时间打磨。
5.2 stdio 模式下的进程生命周期问题
stdio 模式下,MCP Server 是 Host 的子进程。这里有个坑:如果 Server 进程意外退出,Client 不会自动重启它。我遇到过 Server 里有个未捕获的 Promise rejection,进程直接挂了,但 Client 那边只是连接断开,没有任何重连机制,用户看到的就是“工具突然不能用了”。
解决办法是在 Server 入口加全局错误处理:
process.on("uncaughtException", (err) => { console.error("未捕获异常:", err); }); process.on("unhandledRejection", (reason) => { console.error("未处理的 Promise 拒绝:", reason); });同时在 Client 侧监听连接关闭事件,做好重连或者提示用户的准备。生产环境里,我建议再加一层进程守护,Server 挂了自动拉起。
5.3 SSE 模式下的连接稳定性
SSE 模式虽然支持远程调用,但连接稳定性是个大问题。我实测在弱网环境下,SSE 连接平均每 10 分钟就会断一次。如果不做重连处理,用户体验就是“工具时好时坏”。
SDK 本身提供了一些重连支持,但需要你配置好重连策略。我的做法是:
- 设置心跳间隔为 30 秒,服务端定期发送心跳消息保持连接。
- 客户端监听
error和close事件,触发指数退避重连。 - 重连成功后,重新拉取工具列表,因为服务端可能已经更新了。
另外,SSE 模式下认证也是个必须考虑的问题。stdio 模式天然隔离,不用担心未授权访问;SSE 模式暴露在网络里,必须加认证层。我一般用 Bearer Token 做简单认证,复杂场景下会接入 OAuth。
| 问题现象 | 可能原因 | 排查方向 |
|---|---|---|
| 模型不调用工具 | 描述不清晰 | 检查工具名和描述是否具体 |
| 调用参数错误 | schema 定义模糊 | 检查 zod 描述是否明确格式 |
| 连接频繁断开 | 网络不稳定/无心跳 | 检查心跳配置和重连逻辑 |
| 上下文爆满 | 工具返回内容过大 | 检查返回内容长度,加截断 |
| 进程意外退出 | 未捕获异常 | 检查全局错误处理 |
5.4 调试工具链的搭建
MCP 的调试体验在早期版本里比较原始,我摸索出一套还算顺手的调试方法。
首先,用 MCP Inspector。官方提供了一个可视化调试工具,可以列出 Server 的所有工具、手动调用、查看返回结果。启动方式是npx @modelcontextprotocol/inspector,然后它会打开一个网页界面。这个工具在验证工具定义是否正确时特别有用,比写客户端代码快得多。
其次,在 Server 里加结构化日志。我习惯在每次工具调用时打一条日志,包含工具名、参数、执行耗时、返回状态。这些日志打到 stderr,不影响协议通信,但排查问题时非常有用。
最后,写单元测试。MCP Server 的工具逻辑本质上是纯函数,输入参数输出结果,很容易测试。我会为每个工具写几个典型用例,包括正常情况和异常情况。这样在改代码时能快速发现回归问题。
6. 从单机工具到团队共享的演进路径
6.1 什么时候该从 stdio 切到 SSE
一开始用 stdio 完全够用,但以下几种情况出现时,就该考虑切到 SSE 了:
- 多个团队成员需要共用同一套工具,不想每个人本地都装一遍。
- 工具依赖的服务部署在内网服务器上,本地访问不到。
- 需要做统一的权限控制和审计日志。
- 工具的执行需要较长时间,不想占用本地进程。
切换的成本主要在传输层,工具本身的实现逻辑基本不用改。SDK 把传输层抽象得比较好,你只需要把StdioServerTransport换成SSEServerTransport,再补上 HTTP 服务的启动逻辑。
6.2 工具版本管理与向后兼容
当工具被多个客户端依赖时,版本管理就变得重要了。我踩过的坑是:改了一个工具的参数名,结果所有依赖它的客户端全部报错。
现在的做法是:工具的参数只增不减,废弃的参数保留但标记为 deprecated。如果确实需要破坏性变更,就注册一个新名字的工具,旧工具保留一段时间做过渡。MCP 协议本身没有强制的版本协商机制,这部分需要自己在工具设计层面做好。
另外,Server 的name和version字段要如实填写。有些 Host 会根据这个信息做兼容性判断,填错了可能导致连接被拒绝。
6.3 安全边界:哪些能力不该暴露
最后聊一个容易被忽视但很重要的问题:安全边界。
MCP 工具本质上是把外部能力暴露给模型调用,这意味着模型能做什么,取决于你暴露了什么。我见过有人为了图方便,把整个文件系统的读写权限都暴露出去,这非常危险。模型可能因为一个错误的判断就删掉重要文件。
我的原则是:
- 最小权限:每个工具只暴露完成特定任务所需的最小能力。比如查询文件信息,就只读元信息,不要顺带暴露删除功能。
- 路径白名单:文件类工具限制在特定目录下,拒绝访问系统目录和敏感路径。
- 操作确认:对于写操作、删除操作,在 Host 层加二次确认,不要直接执行。
- 审计日志:记录每次工具调用的参数和结果,便于事后追溯。
这些措施会增加一些开发成本,但相比出问题后的代价,完全值得。我在实际项目里见过因为工具权限过大导致的数据误删,恢复起来非常麻烦。
MCP 这个协议目前还在快速演进中,规范细节可能会有调整。但核心思路——用标准化协议连接模型和外部能力——这个方向是确定的。把基础链路跑通,把工具描述打磨好,把安全边界守住,剩下的就是随着生态成熟逐步完善的事了。