1. 从一个反复出现的集成噩梦说起
如果你最近半年在折腾大模型应用,大概率会遇到这样一个场景:你写了一个很顺手的对话助手,接入了文件读取、数据库查询、网页抓取三个工具,代码跑得挺好。结果产品经理过来说,能不能把公司内部的工单系统也接进来?你打开代码一看,工具调用的逻辑和主流程耦合在一起,光是参数校验和错误处理就写了三百行。更麻烦的是,隔壁团队用另一套框架也做了个助手,他们想复用你的工单查询能力,你发现根本抽不出来。
这个问题的本质不是代码写得不好,而是工具接入这件事缺少一个统一的协议层。每个模型厂商有自己的函数调用格式,每个框架有自己的工具注册方式,每个应用有自己的上下文拼装逻辑。你每接一个新工具,就要重新写一遍适配代码;你每换一个模型,就要重新调一遍参数结构。这种重复劳动消耗了大量精力,而且极易出错。
MCP(Model Context Protocol)就是为了解决这个问题而出现的。它做的事情说起来很简单:把"模型需要的外部能力"和"模型本身"之间的通信方式标准化。你可以把它理解成 LLM 应用领域的 USB-C 接口——不管你是鼠标、键盘还是显示器,只要插上这个标准接口,主机就能识别并使用。MCP 定义了一套基于 JSON-RPC 的通信规范,让工具提供方(Server)和能力消费方(Client)之间可以用统一的方式交换上下文信息。
这篇文章适合三类人看:第一类是想给自己的 LLM 应用接入外部工具但被各种适配层折磨的开发者;第二类是想把自己内部系统的能力暴露给 AI 助手使用的平台工程师;第三类是想理解 MCP 到底解决了什么问题、值不值得投入时间学习的技术决策者。我会从协议设计的底层逻辑讲起,拆解核心通信机制,然后给出可落地的 Server 和 Client 实现思路,最后分享一些实际接入中容易踩的坑。
2. MCP 到底标准化了哪些东西
2.1 不是又一个函数调用格式,而是上下文接入的完整抽象
很多人第一次听到 MCP,会以为它只是另一个"函数调用规范",类似于 OpenAI 的 Function Calling 或者 Anthropic 的 Tool Use。这个理解只对了一小部分。函数调用规范解决的是"模型如何表达它想调用某个工具"这个问题,而 MCP 解决的是整个上下文接入链路的标准化。
具体来说,MCP 标准化了以下几个层面的事情。第一是能力的描述方式:一个 Server 能提供哪些工具、每个工具接受什么参数、返回什么结构,这些都用统一的 schema 来描述。第二是通信的传输方式:Client 和 Server 之间怎么建立连接、怎么发送请求、怎么接收响应,协议规定了标准的传输层选项。第三是上下文的组织方式:除了工具调用,MCP 还定义了资源(Resources)和提示模板(Prompts)的概念,让模型可以获取结构化的上下文信息,而不仅仅是调用一个函数拿返回值。
这三层抽象加在一起,才构成了 MCP 的完整价值。你可以这样理解:函数调用规范是"动词"层面的标准化,告诉模型怎么"做一件事";MCP 是"名词+动词+语法"层面的标准化,告诉模型有哪些东西可以用、怎么用、用完怎么组织结果。
2.2 JSON-RPC 2.0 作为通信基座的选择逻辑
MCP 选择 JSON-RPC 2.0 作为消息格式,这个决定值得展开说说。JSON-RPC 是一个很老的协议规范,它的核心结构非常简单:请求包含jsonrpc、method、params、id四个字段,响应包含jsonrpc、result或error、id。就这么简单。
为什么不用 REST?因为 REST 是面向资源的,而 MCP 的交互模式更接近"远程过程调用"——Client 要执行一个具体的方法,比如tools/list列出所有工具、tools/call调用某个工具。用 RPC 风格更自然。为什么不用 gRPC?因为 gRPC 依赖 HTTP/2 和 Protobuf,对很多轻量级场景来说太重了,而且调试不如 JSON 直观。JSON-RPC 的好处是:人类可读、实现简单、传输层无关——你可以用 stdio 传,也可以用 HTTP 传,甚至可以用 WebSocket 传。
这里有一个容易被忽略的细节:JSON-RPC 的id字段是支持异步和批量请求的关键。Client 可以同时发出多个请求,每个请求带不同的id,Server 处理完后按id返回结果,顺序可以打乱。这个设计在工具调用场景下很实用,因为有些工具执行快、有些执行慢,如果串行等待会浪费大量时间。
2.3 三种核心原语:Tools、Resources、Prompts 的分工
MCP 定义了三种核心原语,它们各自解决不同的问题,很多人一开始会混淆。
Tools是最常用的,它代表"模型可以执行的动作"。比如查询数据库、发送邮件、调用外部 API。Tool 的调用是有副作用的,模型需要明确知道自己在做什么。每个 Tool 用 JSON Schema 描述输入参数,Server 负责执行并返回结果。
Resources代表"模型可以读取的数据"。它和 Tool 的区别在于:Resource 是只读的、被动的,模型不需要"执行"什么,只需要"获取"内容。比如一个文件的内容、一个数据库表的 schema、一段配置信息。Resource 用 URI 来标识,比如file:///path/to/doc.md或者db://users/schema。
Prompts代表"预定义的提示模板"。它允许 Server 向 Client 暴露一些可复用的提示结构,Client 可以选择使用这些模板来构造请求。这个原语在实际应用中使用频率相对较低,但在需要标准化交互模式的场景下很有价值。
理解这三者的分工,是设计 MCP Server 的基础。我见过不少开发者把所有东西都塞进 Tools 里,结果 Resource 能做的事也写成了 Tool,导致模型需要"调用"一个函数才能读取一段静态文本,既浪费 token 又增加了出错概率。
3. 传输层选型:stdio 与 Streamable HTTP 的适用边界
3.1 stdio 模式:本地进程间通信的简洁方案
stdio 是 MCP 最早支持的传输方式,也是实现起来最简单的一种。它的工作原理是:Client 启动 Server 作为一个子进程,然后通过标准输入(stdin)和标准输出(stdout)交换 JSON-RPC 消息。每条消息占一行,用换行符分隔。
这种方式的优势非常明显。第一是零网络配置:不需要考虑端口、防火墙、证书这些东西,进程启动就能通信。第二是生命周期天然绑定:Client 启动 Server,Client 退出时 Server 也跟着结束,不会留下孤儿进程。第三是安全性好:通信完全在本机进程间进行,不暴露任何网络端口。
但 stdio 的局限性也很明显。它只能用于本地场景,无法跨机器通信。而且 Server 必须是一个可以独立启动的进程,如果你的能力是嵌在一个大系统里的,抽出来做成独立进程会增加部署复杂度。另外,stdio 模式下 Server 的日志输出要特别小心——如果你往 stdout 打印了非 JSON-RPC 格式的内容,Client 解析就会出错。正确的做法是把日志写到 stderr,或者写到文件里。
实操提示:在 stdio 模式下调试时,千万不要用
console.log往标准输出打日志。我见过至少三个项目因为这个原因导致 Client 端报"invalid JSON"错误,排查了半天才发现是日志污染了通信通道。
3.2 Streamable HTTP:远程接入的主流选择
当你的 MCP Server 需要部署在远程服务器上,或者需要被多个 Client 共享时,Streamable HTTP 就是更合适的选择。它是 MCP 规范中定义的 HTTP 传输方式,支持两种模式:普通的请求-响应模式,以及基于 Server-Sent Events 的流式模式。
普通模式下,Client 向 Server 的/mcp端点发送 POST 请求,请求体是 JSON-RPC 消息,Server 返回 JSON-RPC 响应。这个模式实现简单,适合大多数工具调用场景。流式模式下,Client 先发一个 GET 请求建立 SSE 连接,Server 可以通过这个连接主动推送消息给 Client,适合需要长时间运行或需要 Server 主动通知的场景。
Streamable HTTP 的一个关键设计是会话管理。Server 在初始化响应中返回一个Mcp-Session-Id,Client 后续的请求都要带上这个 header。这样 Server 就能区分不同 Client 的会话状态。如果你的 Server 是无状态的,也可以不实现会话管理,但那样就无法支持需要保持状态的交互。
这里有一个实际部署时经常遇到的问题:反向代理的超时设置。如果你的 Server 后面挂了 Nginx 之类的代理,默认的 60 秒超时可能会导致长耗时工具调用被中断。你需要把proxy_read_timeout调大,或者让工具调用走异步模式——先返回一个任务 ID,Client 再轮询结果。
3.3 选型决策表:什么场景用什么传输
| 场景特征 | 推荐传输方式 | 理由 |
|---|---|---|
| 本地开发调试 | stdio | 零配置,启动快,日志直观 |
| 单机桌面应用 | stdio | 生命周期绑定,无网络暴露 |
| 团队共享的工具服务 | Streamable HTTP | 多 Client 接入,集中管理 |
| 需要 Server 主动推送 | Streamable HTTP + SSE | 支持服务端发起消息 |
| 工具执行时间超过 30 秒 | Streamable HTTP + 异步任务 | 避免连接超时 |
| 对安全性要求极高的内网 | stdio | 不暴露网络端口 |
这个表不是绝对的,实际选型还要考虑你的部署环境、团队技术栈和运维能力。但核心原则是:能用 stdio 就用 stdio,需要远程共享时才上 HTTP。很多团队一上来就搞 HTTP 部署,结果增加了不必要的运维负担。
4. 从零实现一个 MCP Server 的关键步骤
4.1 能力声明:让 Client 知道你能做什么
MCP Server 启动后,Client 会先发送initialize请求,Server 需要在响应中声明自己支持的能力。这个声明包括:支持哪些协议版本、是否支持 Tools、是否支持 Resources、是否支持 Prompts、是否支持日志等。
{ "jsonrpc": "2.0", "id": 1, "result": { "protocolVersion": "2024-11-05", "capabilities": { "tools": { "listChanged": true }, "resources": { "subscribe": false, "listChanged": true }, "prompts": { "listChanged": false } }, "serverInfo": { "name": "my-tool-server", "version": "1.0.0" } } }这里的listChanged表示当工具列表发生变化时,Server 是否会主动通知 Client。如果你的工具是动态注册的,这个字段要设为true,否则 Client 可能一直用缓存的旧列表。
初始化完成后,Client 会发送notifications/initialized通知,表示握手完成。之后就可以正常调用tools/list、tools/call等方法了。
4.2 工具注册:JSON Schema 描述的艺术
每个 Tool 的定义包含name、description和inputSchema三个核心字段。name是工具的唯一标识,description是给模型看的自然语言说明,inputSchema是 JSON Schema 格式的参数定义。
这里有一个很多人忽略的点:description 的质量直接决定模型能否正确使用这个工具。我见过太多项目把 description 写成"查询数据"这种模糊描述,结果模型根本不知道什么时候该调用它。好的 description 应该包含:这个工具做什么、什么时候用、参数的含义、返回值的结构。
{ "name": "query_user_orders", "description": "根据用户ID查询该用户的订单列表。当用户询问'我的订单'、'购买记录'等问题时使用此工具。返回订单号、金额、状态和创建时间。", "inputSchema": { "type": "object", "properties": { "user_id": { "type": "string", "description": "用户的唯一标识符,通常是UUID格式" }, "status": { "type": "string", "enum": ["pending", "paid", "shipped", "completed"], "description": "可选,按订单状态过滤" }, "limit": { "type": "integer", "minimum": 1, "maximum": 100, "default": 20, "description": "返回的最大订单数量" } }, "required": ["user_id"] } }注意enum和default的使用。enum限制了参数的取值范围,让模型不会传入无效值;default告诉模型这个参数可以省略。这些细节看起来小,但能显著降低模型调用出错的概率。
4.3 请求处理:从 tools/call 到结果返回
当 Client 调用tools/call时,请求体包含工具名称和参数:
{ "jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": { "name": "query_user_orders", "arguments": { "user_id": "u-12345", "status": "paid", "limit": 10 } } }Server 收到请求后,需要做几件事:验证参数是否符合 schema、执行实际逻辑、把结果包装成 MCP 规定的格式返回。返回格式中,content是一个数组,每个元素可以是文本、图片或资源引用。
{ "jsonrpc": "2.0", "id": 2, "result": { "content": [ { "type": "text", "text": "找到 3 个已支付订单:\n1. 订单号 ORD-001,金额 299 元,创建于 2024-01-15\n2. 订单号 ORD-002,金额 158 元,创建于 2024-01-18\n3. 订单号 ORD-003,金额 89 元,创建于 2024-01-20" } ], "isError": false } }如果执行出错,isError设为true,content里放错误描述。这里有一个设计决策:错误信息是给模型看的,不是给开发者看的。所以错误描述要写得让模型能理解并可能自我纠正,而不是抛一个堆栈信息。比如参数格式错误时,应该返回"user_id 必须是字符串格式,你传入的是数字",而不是"TypeError: expected str, got int"。
4.4 日志与可观测性:别让调试变成盲人摸象
MCP 定义了notifications/message方法,Server 可以通过它向 Client 发送日志消息。日志有级别之分:debug、info、warning、error。Client 可以选择把这些日志展示给用户,或者写入自己的日志系统。
实际开发中,我建议把日志分成两类:协议日志和业务日志。协议日志记录 JSON-RPC 消息的收发,用于排查通信问题;业务日志记录工具执行的过程和结果,用于排查逻辑问题。协议日志可以通过 MCP 的日志通知发送,业务日志写到本地文件或发送到你的日志收集系统。
注意:不要在工具执行过程中同步写大量日志到 stdout,这会阻塞通信。如果必须写,用异步方式或者写到 stderr。
5. Client 端的接入策略与上下文管理
5.1 连接建立:初始化握手的完整流程
Client 端的接入从建立连接开始。如果是 stdio 模式,Client 需要启动 Server 进程并获取 stdin/stdout 句柄;如果是 HTTP 模式,Client 需要向 Server 的端点发送请求。
初始化握手分三步。第一步,Client 发送initialize请求,带上自己支持的协议版本和客户端信息。第二步,Server 返回自己的能力声明。第三步,Client 发送notifications/initialized通知,握手完成。
这个流程看起来简单,但有一个容易出错的点:协议版本协商。如果 Client 和 Server 支持的版本不一致,需要有一方做出妥协。规范建议 Client 在initialize请求中带上自己支持的版本,Server 如果支持就返回相同版本,如果不支持就返回自己支持的版本,Client 再决定是否继续。
5.2 工具发现与缓存:什么时候该刷新列表
Client 在握手完成后,通常会调用tools/list获取所有可用工具。这个列表可以缓存起来,避免每次调用都重新获取。但缓存有一个问题:如果 Server 的工具列表发生了变化,Client 怎么知道?
答案在capabilities.tools.listChanged字段。如果 Server 声明支持列表变更通知,那么当工具列表变化时,Server 会发送notifications/tools/list_changed通知,Client 收到后重新调用tools/list刷新缓存。如果 Server 不支持这个通知,Client 就需要定期轮询,或者在每次会话开始时重新获取。
实际项目中,我建议在每次会话开始时重新获取工具列表,而不是长期缓存。因为工具列表通常不会太大,重新获取的成本很低,但用错工具列表的代价可能很高。
5.3 把工具描述注入模型上下文的技巧
Client 获取到工具列表后,需要把这些工具的描述转换成模型能理解的格式,注入到模型的上下文中。不同的模型有不同的工具调用格式,但核心信息是一样的:工具名称、功能描述、参数 schema。
这里有一个 token 消耗的优化点。工具描述会占用模型的上下文窗口,如果工具很多,描述很长,可能会挤占实际对话的空间。优化策略包括:只注入当前场景相关的工具、压缩 description 的长度、把详细的参数说明放到 Resource 里按需加载。
另一个技巧是工具分组。如果你的 Server 提供了几十个工具,可以按功能分组,在系统提示中先告诉模型有哪些组,模型需要时再展开具体工具。这样能显著减少初始上下文的 token 消耗。
5.4 处理工具调用结果:文本、图片与结构化数据
工具调用的返回结果可能是多种类型的。最常见的是文本,直接拼接到对话历史里就行。图片需要特殊处理——有些模型支持直接理解图片,有些不支持,Client 需要根据模型能力做转换。结构化数据(比如 JSON)可以选择直接返回给模型,也可以先格式化成可读文本。
这里有一个实际经验:返回给模型的结果要尽量简洁。我见过一个工具返回了完整的数据库查询结果,几千行 JSON,直接把模型的上下文撑爆了。正确的做法是在 Server 端做聚合和摘要,只返回模型需要的关键信息。比如查询订单,返回"共 3 个订单,总金额 546 元,最近一笔是 1 月 20 日"就够了,不需要把每条记录的每个字段都返回。
6. 实际接入中那些文档没写的坑
6.1 参数校验失败时的错误信息设计
参数校验失败是最高频的错误场景。很多 Server 实现直接返回 JSON Schema 的校验错误,比如"user_id" does not match pattern "^[0-9a-f]{8}-"。这种错误信息对开发者有用,但对模型来说太晦涩了,模型看不懂就无法自我纠正。
好的错误信息应该包含三要素:哪里错了、期望什么、实际收到什么。比如:"参数 user_id 格式不正确。期望是 UUID 格式(如 550e8400-e29b-41d4-a716-446655440000),你传入的是 '12345'。请检查后重新调用。"
这样的错误信息模型能理解,下一轮调用时就会修正。实测下来,这种错误信息能把参数错误的自我纠正率从不到 30% 提升到 80% 以上。
6.2 长耗时工具的超时与异步处理
有些工具执行时间很长,比如调用外部 API 做数据分析、生成报表、批量处理文件。如果同步等待,很容易触发 Client 或中间代理的超时。
处理方案有两种。第一种是异步任务模式:工具调用立即返回一个任务 ID,模型告诉用户"任务已提交,请稍后查询",然后模型再调用另一个工具查询任务状态。第二种是流式返回:Server 通过 SSE 逐步返回进度,Client 实时展示给用户。
第一种方案实现简单,适合大多数场景。第二种方案体验更好,但实现复杂度高。选择哪种取决于你的具体需求和团队能力。
6.3 多 Server 场景下的工具命名冲突
当 Client 同时连接多个 MCP Server 时,不同 Server 可能提供同名工具。比如两个 Server 都有search工具,一个搜网页,一个搜内部文档。这时候 Client 需要做命名空间隔离,比如给工具名加上 Server 前缀:web_search和doc_search。
这个处理应该在 Client 端做,而不是要求 Server 改工具名。因为 Server 是独立开发的,不应该知道其他 Server 的存在。Client 在注入工具描述时,把server_name.tool_name作为完整标识,调用时再拆分出实际的 Server 和工具名。
6.4 安全边界:哪些能力不该暴露给模型
MCP 让模型可以调用外部能力,这带来了便利,也带来了风险。不是所有能力都适合暴露给模型。我建议遵循以下原则:
- 写操作要谨慎:删除数据、发送消息、修改配置这类有副作用的操作,要么不暴露,要么加上确认机制。
- 敏感数据要过滤:工具返回的结果中如果包含密码、密钥、个人隐私信息,要在 Server 端过滤掉。
- 权限要最小化:Server 执行工具时使用的账号权限应该是最小的,只够完成工具功能即可。
- 调用要可审计:每次工具调用都要记录日志,包括谁调的、调了什么、参数是什么、结果是什么。
实操提示:在开发阶段,可以给工具加上"dry run"模式,只返回将要执行的操作而不实际执行。这样既能测试工具逻辑,又不会产生副作用。
7. 我对 MCP 落地节奏的一点判断
MCP 这个协议本身并不复杂,JSON-RPC 加几个方法定义,一两天就能把基本流程跑通。真正花时间的是工具的设计和错误处理——怎么把业务能力拆成合适的工具粒度、怎么写让模型能理解的描述、怎么处理各种边界情况。这些工作没有标准答案,需要在实践中不断调整。
我自己的经验是,先从一个最简单的工具开始,跑通整个链路,然后再逐步增加工具数量和复杂度。不要一上来就设计一个大而全的 Server,那样很容易在细节里迷失。另外,多观察模型实际调用工具的行为,你会发现很多设计时没想到的问题——比如模型可能会用你没想到的参数组合、可能会连续调用同一个工具、可能会忽略你精心设计的错误提示。根据这些观察来迭代你的工具设计,比闭门造车有效得多。
这个协议还在快速演进中,规范版本从最初的草案到现在已经更新了好几轮。建议在实现时把协议版本作为可配置项,方便后续升级。同时关注社区的实现和讨论,很多坑已经有人踩过了,没必要自己再踩一遍。