1. Agent接入工具的乱局,以及MCP为什么能收拾这个摊子
过去大半年,我深度跟进了一批Agent项目的工具层实现,一个越来越明显的感受是:当Agent从“单轮问答”走向“真正干活”的阶段,工具的接入方式会成为整个系统最容易翻车的地方。每个服务商都有一套自己的工具调用协议,有的走REST回调,有的走WebSocket推送,有的干脆把工具逻辑直接写在Prompt里让模型“看着办”。结果就是Agent框架和工具之间耦合得死死的,换一个模型供应商,工具层就要重写一遍;换一个Agent框架,之前的工具适配代码全部作废。
MCP(Model Context Protocol)就是冲着这个乱局来的。它由Anthropic在2024年底提出并开源,随后OpenAI、Google等厂商陆续跟进,短短半年多就成为Agent工具层事实上最受关注的开放标准。MCP的定位非常清晰:它不是一个SDK,不是一个平台,也不是某个模型厂商的私有协议,而是一个应用层的开放协议,用来统一“模型/Agent应用”和“外部工具/数据源”之间的通信方式。
在MCP的架构里,整个协议栈有两个关键底座:底层的JSON-RPC 2.0负责消息格式和远程调用语义,上层的三大原语——Tools、Resources、Prompts——负责定义模型能跟外部世界发生哪几类关系。这两个底座合在一起,构成了一个完整的、标准化的Agent工具层。
这篇文章我想从协议栈的底层往上逐层拆解,把这个东西彻底讲透。内容会比较硬核,适合正在做Agent开发的工程师、准备自建Agent框架的架构师,以及所有想搞清楚“Agent的工具层到底该怎么设计”的人。我会先用一个真实场景说明MCP要解决的问题,然后逐层拆JSON-RPC、三大原语、完整调用链路,最后把我在Server端实现里踩过的坑和总结一并放上来。
先说一个我实际碰到的场景。我负责过一个内部运维助手,需要调用十几个内部系统的接口——监控系统、发布系统、工单系统、数据库查询平台等等。最初的做法是给每个系统写一段Python函数,然后把这些函数塞进Agent的工具列表。第一版跑得挺顺,但问题很快暴露了:工具一多,参数格式开始各搞各的;有的系统需要先鉴权再做调用,有的直接在函数里内置了token;模型经常把参数类型传错,而每个函数报错信息写得还都不一样。更要命的是,换一个Agent框架时,这些函数原本的入参描述格式完全没法迁移。
MCP把这类问题压缩成了一个清晰的三层模型:传输层负责把消息送到,JSON-RPC层负责把调用语义表达清楚,原语层负责把工具的类型边界划明白。理解了这三层各自干什么,再看任何一个MCP的实现,都是顺理成章的事。
2. JSON-RPC 2.0是MCP的地基:报文格式、方法命名与传输选择
2.1 为什么偏偏是JSON-RPC,而不是REST或gRPC
MCP协议栈的第一层选择是JSON-RPC 2.0。这个选择背后是有明确逻辑的,不是随便挑一个顺手的RPC框架。
REST对Agent场景来说太“散”了。REST的本质是资源导向的HTTP方法映射,一个操作要拆成URL、Method、Header、Body四个维度去描述,每个服务的设计风格还不同。Agent要做一次工具调用,得先搞清楚“这个操作用GET还是POST、路径怎么拼、参数放哪”,这对模型来说太折磨。gRPC又太重了,需要IDL编译、HTTP/2、强类型序列化,接入成本和部署复杂度都高,不适合做开放协议。
JSON-RPC 2.0恰好站在两者中间:它足够简单,协议规范全文只需几页纸就讲完;它足够通用,基于JSON序列化,任何语言任何运行时都能实现;它足够结构化,请求、响应、错误都有严格对象格式,不会出现REST那种“一百个人一百种风格”。MCP选JSON-RPC,核心是看中了它的极简和确定性。
2.2 四种报文对象:请求、响应、通知、错误
JSON-RPC 2.0规范定义了四种核心对象,MCP全都在用,而且在上面做了自己的扩展。
请求对象固定有四个字段,除了method和params,还有jsonrpc和id。id非常关键,它让请求和响应形成一一对应的关系,客户端收到一个响应时能立刻知道它对应的是哪次调用。MCP里工具调用、资源读取、能力协商,全走这个机制。
响应对象有result或error二选一的约束。一个细节是,result和error永远不会同时出现,这是JSON-RPC的一个硬性要求。MCP的Server端如果实现得不严谨,在这就容易翻车。
通知对象是没有id的请求。发送方不期待任何响应,跟UDP一样是“发完即忘”。MCP里大量使用通知做事件广播,比如工具列表变化、资源内容更新、日志输出。这个设计很好用,因为并不是所有通信都需要回应。
错误对象里最重要的是错误码。JSON-RPC预定义了五个标准错误码:-32700解析错误、-32600无效请求、-32601方法不存在、-32602参数无效、-32603内部错误。MCP Server在返回业务错误时(比如工具执行超时、资源不存在),一般用-32603或自定义的服务器错误码,配合data字段里带上更详细的业务错误信息,不要让模型从一个裸的数字错误码里猜问题。
2.3 MCP在JSON-RPC之上加了什么
JSON-RPC只规定了“消息长什么样”,没有规定“消息拿去做什么”。MCP的工作是把它变成一套有业务语义的协议,加的第一层东西就是方法论。
所有MCP方法都是点分字符串,格式是category/action。比如Tools这一组的三个方法:tools/list、tools/call、tools/notify_changed。客户端请求一个工具列表,发的是一个jsonrpc: "2.0"、method: "tools/list"的消息,Server端返回一个包含工具定义数组的result。这个设计让协议层的语义边界非常清楚,你不用读文档也能猜到某个方法大概干什么。
MCP加的第二层东西是初始化握手。JSON-RPC本身是无状态的,MCP在会话开始时通过initialize请求做一次能力协商,把所有状态一次性敲定。客户端和服务端各自声明自己支持的协议版本、能力集合、产品名称和版本号。这一步很像TCP的SYN握手,没做完这个,后面任何业务调用都不能开始。
MCP加的第三层东西是会话和传输的规范化。JSON-RPC只定义消息格式,不关心消息怎么送达。MCP定义了两种标准传输:一种是stdio,Server作为子进程启动,通过标准输入输出走换行分隔的JSON消息;另一种是streamable HTTP,通过HTTP的POST端点做流式传输,支持SSE(Server-Sent Events)推送服务端消息。选择哪种传输取决于部署环境:stdio适合本地进程内集成,比如让Agent直接拉起一个Python脚本作为工具服务;Streamable HTTP适合远程服务部署,比如内部把监控系统封装成一个MCP Server,跑在独立机器上供多个Agent实例调用。
2.4 一次真实的initialize请求长什么样
看一个实际报文就会清楚整个机制。客户端发起initialize请求:
{ "jsonrpc": "2.0", "id": 1, "method": "initialize", "params": { "protocolVersion": "2025-06-18", "capabilities": { "tools": { "listChanged": true }, "resources": { "subscribe": true, "listChanged": true }, "prompts": { "listChanged": true } }, "clientInfo": { "name": "my-agent", "version": "1.0.0" } } }服务端收到后,返回自己的协议版本和能力声明:
{ "jsonrpc": "2.0", "id": 1, "result": { "protocolVersion": "2025-06-18", "capabilities": { "tools": { "listChanged": true } }, "serverInfo": { "name": "internal-tools-server", "version": "0.3.2" } } }注意能力协商的结果是交集,而不是服务端必须满足客户端所有的能力声明。比如客户端声明支持资源订阅,服务端没声明,那客户端就应该自动降级,不调用订阅相关的方法。我见过不少初学MCP的人在这里踩坑,看到客户端声明了什么就以为服务端一定会支持什么,结果运行时到处报方法未找到。
初始化完成之后,客户端还要再发一个notifications/initialized通知,告诉服务端“握手彻底结束,可以发业务请求了”。这里有个隐含的顺序要求:客户端在发送notifications/initialized之前,不应该调用任何其他业务方法。而服务端在收到这个通知之前,也不应该主动向客户端推送消息。
3. 三大原语全拆解:Tools、Resources、Prompts的分工与边界
3.1 Tools:会动手的执行者
Tools是三大原语里最核心、也最容易被误解的一个。很多人一听说MCP支持工具调用,就觉得Tools就是“给模型调用的API”。这个理解方向对,但漏了关键一点:Tools的本质是让模型通过协议去触发一个有副作用的操作。
什么叫“有副作用”?就是调用之后外部世界发生了变化。查询订单状态没有副作用,但关闭一笔订单是有副作用的;读文件没有副作用,但写文件是有副作用的。有副作用的操作,就不能简单地由模型自由决定执行,需要做权限控制、操作确认、审计记录。MCP协议里Tools的list和call分离,就是为了让客户端在调用前先看到工具的元信息,再由Agent框架或用户决定是否执行。
Tools在协议里对应一组方法:tools/list获取工具列表,tools/call执行具体的工具调用。一个工具定义在协议层面通常长这个样子:
{ "name": "close_order", "description": "根据订单ID关闭指定的订单。该操作不可恢复,执行前务必与用户确认订单信息无误。", "inputSchema": { "type": "object", "properties": { "order_id": { "type": "string", "description": "订单的唯一标识符,格式为数字字符串" }, "reason": { "type": "string", "description": "关闭订单的原因说明,将会记录在审计日志中" } }, "required": ["order_id", "reason"] } }这里有个非常关键的设计:description字段不是给开发人员看的,是给模型看的。模型通过这个描述来判断“什么时候该用这个工具、什么时候不该用”。如果你把描述写得过于泛泛,比如“关闭订单”,模型可能在不该用的时候也去调用;如果你把约束、适用场景、前置条件都写清楚,模型的调用准确率会明显提升。我在实际项目中验证过,同样一个工具,描述从一句话扩成三句话,调用准确率能提升十几个百分点。
3.2 Resources:只读的上下文提供者
Resources解决的是另一个问题:模型要做出正确决策,往往需要外部上下文,比如数据库表结构、业务文档、系统状态快照。这些内容不该通过Tools去“调用”,因为它们没有副作用,而且内容往往是变化的。
它的协议方法包括:resources/list列出可用的资源,resources/read读取资源内容,resources/subscribe订阅资源变更(如果服务端支持),以及resources/templates/list列出资源模板。
一个典型的资源定义:
{ "uri": "database://internal/orders/schema", "name": "orders表结构", "description": "内部订单系统的数据库表结构说明,包含orders、order_items、payments三张表的字段定义", "mimeType": "text/markdown" }我见过一个特别实用的用法:把内部API的OpenAPI文档直接暴露成一个Resource。模型在准备调用某个工具之前,先去读取这个资源,搞清楚接口的字段含义,再发起tools/call。这比把几十页接口文档硬塞进系统Prompt效果好得多,既省token,又不会因为上下文太长影响模型的判断。
3.3 Prompts:可复用的引导模板
Prompts是三大原语里最容易被忽略、但实战价值很高的一个。它本质上是一段可复用的提示词模板,由Server端定义,客户端按需获取。
为什么需要这个?想象你的Agent团队有几十个固定场景:周报生成、数据库问题排查、代码Review。每个场景的Prompt经过反复调优,效果很好。但这些Prompt散存在各个人的本地文件里,很难统一管理。MCP的Prompts原语解决的就是这个问题:把调优后的Prompt放到Server端,通过prompts/list获取模板列表,通过prompts/get按名称和参数取具体内容。团队成员无论用什么Agent框架,都能拿到同一套调优过的Prompt。
一个标准Prompts定义的简化示例:
{ "name": "daily_report", "description": "根据工作日志生成结构化日报", "arguments": [ { "name": "work_log", "description": "当天的工作记录,每行一条任务", "required": true } ] }客户端调用prompts/get拿到的不只是一段文本,而是一个消息数组,支持插入多角色内容,比如系统指令、用户指令、示例输出。这比“拼字符串”的方式规范得多。
3.4 三大原语的本质:按副作用程度分层
把三大原语放一起看,你会发现MCP的设计逻辑其实是按照副作用程度对模型的外部交互方式做分层。
- Resources:只读、无副作用、永远安全,模型可以自动读取。
- Prompts:无副作用,本质是文本组合,模型或用户主动获取,不需要执行权限。
- Tools:可能有副作用,需要更严格的调用控制,通常是用户确认或Agent框架决策后执行。
这个分层的价值在真实系统里体现得非常明显。没有分层的Agent框架,所有交互都走“工具调用”,那就要为所有调用做权限控制和审计,成本很高。有分层的框架,让该自由读的自动读,该模板化的走模板,该确认的执行前确认。权限、安全、审计的使用范围由此极大收敛。
我自己在实践中的做法是:凡是只读信息查询,优先暴露成Resource;凡是多轮变对话的固定流程,优先写成Prompt;凡是产生实际业务效果的操作(写库、发消息、改配置),才定义为Tool。这套筛选规则执行下来,工具层的安全性管理和Agent调用的准确性都有明显提升。
4. 一次完整交互的调用链路:从Agent请求到结果回传全流程
4.1 客户端初始化阶段做了什么
我们经常看到MCP官方文档里画着Client和Server两个盒子,中间用连接线串起来,但真实的调用时序比示意图复杂。我把一次真实的Agent工具调用完整拆开,从建立会话开始讲。
第一步是传输握手。如果走stdio,Agent进程会拉起一个子进程跑Server服务,建立标准输入输出管道;如果走Streamable HTTP,Agent会先向Server的HTTP端点发起连接。这个阶段的目的是确认“通信链路通了”。
第二步就是上一节讲过的initialize握手。客户端发initialize请求,带上自己支持的协议版本和能力。服务端返回它支持的版本和能力。这里有个协议版本选择的细节:请求里带的是客户端最高支持的版本,如果服务端也支持就直接用;如果服务端只支持旧版本,则返回旧版本号,客户端必须接受降级。协议版本协商的本质是“取两个集合的交集”,而不是谁说了算。
第三步是notifications/initialized通告。这一步很多SDK会自动完成,但在手写协议实现时容易漏掉。漏掉的后果是Server端一直认为会话还没准备好,后续的某些推送和会话级功能会静默失效。
4.2 动态工具发现到实际调用的完整时序
初始化完成后,客户端要做的第一件事常常是tools/list,把所有可用工具拉下来。为什么不是收到工具列表后一股脑塞进模型上下文?因为工具太多会占用大量token,而且有些工具的触发条件很少见。成熟的框架会做按需加载,先拿一个粗粒度描述,等模型主动请求某个命名空间下的工具时再拉详细定义。
假设Agent收到用户指令:“帮我把昨天的订单汇总一下,顺便看看有没有超时未发货的”。经过模型推理,它决定调用list_orders工具。完整调用过程如下:
第一步:获取工具定义。
模型在推理阶段发现需要调用list_orders,Agent框架先检查本地缓存的工具列表中是否已有该工具的详细schema。如果没有,先掉用tools/list获取。
{ "jsonrpc": "2.0", "id": 3, "method": "tools/list" }服务端返回工具列表。这里有一点很重要:工具列表是静态的吗?不是。MCP支持动态工具发现,服务端可以在运行过程中通过tools/notify_changed通知客户端“工具列表变了”,客户端收到后可以重新拉取。这个机制对动态注册工具的场景非常有用。
第二步:模型生成工具调用参数。
模型根据用户请求和工具schema,生成一次调用:
{ "jsonrpc": "2.0", "id": 7, "method": "tools/call", "params": { "name": "list_orders", "arguments": { "date_from": "2025-01-01", "date_to": "2025-12-31", "status": "unshipped" } } }注意arguments里的字段值不是程序写死的,而是模型根据对话上下文推理出来的。如果模型选错工具或生成错误参数,这次调用就会以错误响应返回。
第三步:服务端执行工具并返回结果。
服务端收到调用请求后,按参数执行内部逻辑,返回结构化结果:
{ "jsonrpc": "2.0", "id": 7, "result": { "content": [ { "type": "text", "text": "共找到12笔超时未发货订单,最近一笔订单ID: A20251201001,金额: 3680元" } ], "isError": false } }这里有个协议细节:isError为true时表示工具执行本身完成了,但是业务上失败了(比如参数不合法、业务规则不允许),而不是协议层发生了错误。协议层错误走JSON-RPC的error字段,业务层失败走isError字段。这两者的区别必须搞清楚,否则错误处理逻辑会出大问题。
4.3 工具调用的错误分类与处理策略
我根据经验把MCP工具调用中的错误分成三类,处理策略截然不同。
第一类是协议层错误,对应JSON-RPC错误码里的-32600/32601/32602/32603。这类错误说明调用请求本身有问题,重试大概率还是失败,正确的做法是立即报告给Agent框架,不要盲目重试。
第二类是业务层错误,即上文提到的isError: true。这类错误里,工具确实执行了,只是结果不好。比如list_orders返回“时间段内没有订单”。这类错误不应该中断Agent的运行,而应该作为上下文喂回给模型,让模型决定下一步怎么做。
第三类是传输层错误,比如HTTP超时、连接中断。这类错误无法预知,通常需要设计重试策略。我的经验是重试最多三次:第一次立即重试,第二次等2秒,第三次等5秒。超过三次直接放弃,把错误信息反馈给Agent框架做降级处理。
4.4 长时间任务的进度反馈
如果工具执行时间很长(比如跑一个数据加工任务),MCP提供了一套通知机制,服务端可以周期性地往客户端发送notifications/progress通知。通知里带上进度百分比和当前阶段描述,客户端把进度显示在界面上。这个机制特别适合内部工具平台的人性化需求,不然一个长时间无响应的工具调用,用户会以为Agent卡死了。
不过要提醒的是,progress通知依赖于服务端实现主动性。如果你的Server就是简单的请求-响应模式,没有后台任务线程,这个功能是天然缺失的。选型的时候要看清楚SDK是否支持。
5. Server端实现中最容易翻车的六个细节
5.1 能力协商中“声明即承诺”的坑
这是我在自研MCP Server时踩过最深的坑。MCP的能力协商机制是“客户端声明什么,服务端就按什么预期服务”。如果客户端在capabilities里声明了tools.listChanged为true,那就意味着服务端有义务在工具列表变动时主动给客户端发通知。
我一开始没发通知,结果接入了OpenAI的Agent SDK后,发现工具的增删改一直不同步。调试了很久才知道人家按协议逻辑,既然你声明支持listChanged,你就有义务通知我。要么你别声明,要么你实现了再声明。声明即承诺,这个原则在MCP里是硬约束。
5.2 工具描述不当导致模型乱调用
工具描述的质量直接影响模型调用的准确率。模型不是人,它不会从代码注释里理解工具的语义,只从description字段里获取使用线索。
我总结了一个好用的描述模板,包含五要素:
- 工具是做什么的(一句话)
- 什么时候应该用它(触发条件)
- 什么时候不应该用它(避免误用)
- 关键参数的含义和格式要求
- 调用后会产生什么效果(副作用说明)
描述写得好,模型就知道什么情况该调用什么工具,参数也不会传错。更隐蔽的一个坑是:工具的name字段用太长或者太模糊的命名,模型就经常把不同工具搞混。我建议用统一的命名规范,比如[实体]_[操作],order_query、order_close,保持一致性,模型才能更准确地区分。
5.3 生命周期管理:会话结束不等于进程结束
MCP的Server生命周期设计里,会话结束和进程退出是两个不同的概念。在stdio模式下,客户端通常会通过关闭标准输入流来通知服务端“会话结束”。服务端应该优雅地清理资源(关闭数据库连接、释放锁),然后退出进程。
我见过一个实现是全靠在收到SIGTERM信号时才做清理,结果频繁出现连接泄漏。正确做法是:在会话结束时主动触发清理逻辑,而不是被动等待外部信号。另外,进程退出前应当释放共享锁之类的资源,避免下次启动时发现锁文件残留。
5.4 错误信息的可读性问题
协议层的错误码范围很有限,业务失败全部归到-32603上,信息很容易糊成一团。我的经验是:错误信息要写给模型看,不是写给人看。
模型拿到一个错误后,会尝试基于错误信息进行下一步行动。如果你的错误信息是“调用失败”,模型根本无从下手;如果你的错误信息是“订单ID不存在,请检查订单ID是否输入正确,或者换一个订单ID再试”,模型就能做出合理的下一步决策。在result.error.data里带上足够的业务上下文,是提升Agent多轮任务成功率非常有效的手段。
5.5 请求超时与并发控制
MCP协议本身没有强制要求请求必须设置超时,但在实际项目中,不设置超时一定会挂。模型的一次决策,可能触发多个工具调用的并发请求。如果某个工具响应很慢,而且Agent还在等它,整个推理链条就被拖住了。合理的做法是给每个tools/call请求设置一个全局超时,比如10秒,超时后立即返回超时错误并通知Agent框架进行降级处理。
同时考虑并发控制。服务端如果同时处理几十个Agent实例的请求,需要做好并发上限限制或请求队列,否则单机内存和连接都会被打满。
5.6 安全边界:你给模型开放的权限,代价有多大
Tools的能力是一把双刃剑。你给Agent开放了一个数据库操作工具,它就有可能在执行查询“误操作”时,删掉一张表。实际案例并不少见。所以要设计好安全层级:
- 只读操作默认开放,执行类操作必须走用户确认
- 特别敏感的操作(删除、批量修改、对外发送消息)强制二次确认
- 对工具的调用范围做业务侧的白名单过滤(比如订单关闭只允许关闭本人名下的订单)
MCP的协议支持isError返回业务错误,不要浪费这个能力,把越权操作的请求一律拒绝并返回清晰错误,让Agent自己处理冲突。
6. 从MCP反向看Agent工具层的设计趋势与经验总结
6.1 接口标准化只是第一步,语义标准化更关键
MCP做到的不只是把工具调用的“接口格式”统一了,更深层的价值在于语义标准化。Tools、Resources、Prompts把“模型能对世界做什么”这个问题压缩成了三类明确的关系:可以执行的(Tools)、可以读取的(Resources)、可以获取模板的(Prompts)。这比每个框架自己发明一套“工具定义”要深刻得多。
我试过把一套MCP Server接到不同的Agent框架上——包括自己写的简易Agent、开源的Agent框架、以及云厂商的Agent服务——只要遵循协议,接入成本都极低。真正标准化的东西,迁移成本趋近于零。
6.2 “标准”的红利在生态,不在单个实现
MCP作为开放协议,最大的红利是生态。目前各大云厂商、向量数据库、开发工具、运维平台都在出MCP Server,意味着未来接入一个外部系统,可能不需要自己封装适配层,而是直接接入对方提供的MCP Server即可。对Agent开发者来说,需要关注的不是协议本身(它已经足够稳定),而是如何在整个Agent系统里设计好工具层的编排逻辑,比如工具的注册发现、鉴权、缓存、审计,以及和Prompt工程的配合。
6.3 三原语使用策略的决定性作用
我做了几个MCP项目之后的体感是:决定工具层好不好的关键,不在于实现,而在于你怎么用这三个原语。
一个比较理想的分层策略是:
- 所有“信息查询”都往Resources方向放,让模型自由读取、随时查询
- 所有“流程引导”都往Prompts方向放,让Agent在需要时自动获取模板
- 只有“真正要影响系统状态的操作”才定义为Tools,并在调用前做确认和审计
这样分完之后,安全边界、权限控制、token消耗、审计成本都变得可控,模型的决策质量也会因为我们限制了工具的“杀伤力”而更高。
6.4 最后聊一个实操体会
MCP还非常年轻,从协议第一批草案到现在也就一年左右,但它的设计已经相当扎实。如果你是刚开始接触这块,我建议不用一上来就深入研究协议规范的所有细节,而是先跑通一个最简单的流程:用官方SDK搭一个包含一个工具的Server,接上一个支持MCP的客户端,把工具调用跑通。跑通之后,再回头读协议规范,很多设计意图自然就理解了。
如果你正准备把团队内部的工具层标准化,我的建议是:先别想着一步到位,从一个高频调用的系统开始做MCP封装,跑顺了再推广到其他系统。
这轮做下来,我对“标准化”这件事有了新的理解。好的标准不是管住所有人不让动,而是划清楚边界,让所有人把精力放到更有价值的业务上。MCP之于Agent生态,现阶段更像是那个“划边界”的角色——它能走多远,还会不会演进出新的形态,都值得继续观察。但对我这种实际在搭Agent工具层的人来说,MCP已经把我从无穷无尽的适配代码里解放出来了。