MCP 工具调用机制详解
MCP 工具调用机制详解
本文档整理自与 Claude 的技术沟通,面向研发同学,讲清楚"Host 程序(如 Copilot)如何驱动大模型调用 MCP 工具"这一完整链路,并以 Claude 官方 API 为例给出真实的 JSON 格式。
一、角色定义
MCP(Model Context Protocol)定义了三方关系:
| 角色 | 说明 |
|---|---|
| Host(宿主程序) | 面向用户的应用,例子中的 Copilot |
| MCP Client | 内嵌在 Host 里,负责和某一个 MCP Server 建立连接(一对一) |
| MCP Server | 独立进程/服务,暴露一批"工具"(tools),比如查天气、读文件、发邮件 |
关键认知:MCP 只规定 Host 与 Server 之间怎么通信,不规定Host 与 LLM(Claude/ChatGPT)之间怎么通信。LLM 那一侧走的是各家自己的 “tool use” / “function calling” API。MCP 的价值在于把"外部工具"这件事标准化,让 Host 不用为每个工具单独写接入代码。
二、启动阶段:Copilot 怎么拿到工具列表
- Copilot 读取 MCP 配置文件(类似
mcp.json),里面写明要连哪些 Server:
{"mcpServers":{"filesystem":{"command":"npx","args":["-y","@modelcontextprotocol/server-filesystem","/path"]},"weather":{"url":"https://xxx.com/mcp"}}}- 对每个配置项,Copilot 内部的 MCP Client 通过stdio(本地进程,消息是换行分隔的 JSON-RPC 文本,读写 stdin/stdout)或HTTP/SSE(远程服务)建立连接,双方走一次完整的
initialize握手,这个握手是三步,不是一步:
第一步:Client 发送initialize请求,带上自己支持的协议版本和能力:
{"jsonrpc":"2.0","id":0,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{"roots":{},"sampling":{}},"clientInfo":{"name":"Copilot","version":"1.0.0"}}}第二步:Server 返回自己的协议版本、能力和信息:
{"jsonrpc":"2.0","id":0,"result":{"protocolVersion":"2025-06-18","capabilities":{"tools":{},"resources":{}},"serverInfo":{"name":"weather-server","version":"0.3.0"}}}第三步:Client 发送notifications/initialized通知,确认握手完成:
{"jsonrpc":"2.0","method":"notifications/initialized"}注意这一步是通知(notification)不是请求:JSON-RPC 里通知没有id字段,Server 收到后也不会返回任何响应——这是 JSON-RPC 协议本身的规则(有id的是请求,必须有响应;没有id的是通知,单向发送,不需要回复)。只有这三步都走完,连接才算真正建立,之后 Client 才能调用tools/list、tools/call等方法。
- 握手成功后,Client 发送标准 JSON-RPC 请求调用
tools/list方法:
{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}Server 返回时,工具数组包在完整的 JSON-RPC 响应信封里,位于result.tools字段下,不是裸数组:
{"jsonrpc":"2.0","id":1,"result":{"tools":[{"name":"get_weather","description":"根据城市名查询实时天气","inputSchema":{"type":"object","properties":{"city":{"type":"string"}},"required":["city"]}}],"nextCursor":"eyJvZmZzZXQiOjEwMH0="}}要点:
id必须和请求里的id对应上,用于匹配是哪次调用的响应;- Copilot 解析时要先剥掉信封,取
response.result.tools才是真正的工具列表; - 如果 Server 上工具很多,一次返回不全会带
nextCursor,Client 需要拿这个游标再发一次tools/list请求翻页,直到响应里不再带nextCursor为止。
- Copilot 把所有 Server(分页翻完后)返回的 tools 汇总成一个列表,缓存在内存里,等待对话时使用。
三、对话阶段:请求怎么发给 Claude
3.1 第一次请求
真实的 HTTP 请求除了 body,还必须带这几个 header,不然会直接被拒绝,这一点经常被简化掉:
POST https://api.anthropic.com/v1/messages Content-Type: application/json x-api-key: <你的API密钥> anthropic-version: 2023-06-01body 部分:
{"model":"claude-sonnet-4-6","max_tokens":1024,"messages":[{"role":"user","content":"北京今天天气怎么样"}],"tools":[{"name":"get_weather","description":"根据城市名查询实时天气","input_schema":{"type":"object","properties":{"city":{"type":"string","description":"城市名,如北京"}},"required":["city"]}}]}这里的tools字段,就是把第二步拿到的 MCP 工具列表翻译成 Claude API 要求的格式。这一步翻译工作由 Copilot 完成,MCP 协议本身不管。
另外需要注意:如果要给 Claude 设定 system prompt(比如"你是一个专业的天气助手"),要用独立的顶层system字段,不能塞进messages数组里当一条role: "system"的消息——这点和 OpenAI 不一样,OpenAI 是把 system 提示当作messages数组里第一条消息处理,Claude 是单独一个字段:
{"model":"claude-sonnet-4-6","max_tokens":1024,"system":"你是一个专业的天气助手,回答简洁、只给结论。","messages":[/* ... */],"tools":[/* ... */]}3.2 该格式是官方规范,不是"约定俗成"
这是一个常见误解,需要澄清:
- 传给 Claude 的 JSON 结构是Anthropic 官方定义、发布、版本化维护的 Messages API 规范,有正式文档,不是行业默契或民间约定。
- 但不同厂商之间(Claude / OpenAI / Gemini)确实没有统一标准,字段名不完全一样,这是行业收敛出的相似模式,不是同一份规范:
| OpenAI | Claude | |
|---|---|---|
| 工具字段名 | tools[].function.parameters | tools[].input_schema |
| 工具包裹层 | 多一层"type": "function" | 无包裹,直接平铺 |
| 触发原因字段 | finish_reason: "tool_calls" | stop_reason: "tool_use" |
| 工具结果角色 | role: "tool" | role: "user"(包在tool_result块里) |
| system prompt 位置 | messages数组里第一条role: "system" | 独立顶层字段system,不进messages |
好消息是:MCP Server 返回的inputSchema本身就是标准 JSON Schema,和 Claude 需要的input_schema几乎是同一个东西,Copilot 做的转换基本只是换字段名、去包装,工作量很小。
四、Claude 返回:要不要调用工具,模型自己判断
如果 Claude 判断需要调用工具,返回结构如下——注意这是完整的响应体,之前为了讲解简化掉了type、model、usage等字段,实际这些字段每次都会返回:
{"id":"msg_01Xyz...","type":"message","role":"assistant","model":"claude-sonnet-4-6","content":[{"type":"text","text":"我需要查一下北京的天气。"},{"type":"tool_use","id":"toolu_01AbC...","name":"get_weather","input":{"city":"北京"}}],"stop_reason":"tool_use","stop_sequence":null,"usage":{"input_tokens":512,"output_tokens":38}}要点:
content是数组,可能同时包含文字块和工具调用块(Claude 有时会先说一句"我去查一下"再调工具)。id: "toolu_01AbC..."非常关键,Copilot 回传结果时必须原样带回去,用于对应"这是哪次调用的结果"(注意这个id在tool_use块里,和最外层message的id: "msg_01Xyz..."是两个不同的东西,不要弄混)。stop_reason: "tool_use"就是 Copilot 判断"该去执行 MCP 工具了"的信号。usage字段每次响应都会带,记录这一轮消耗的 token 数,做成本核算或者上下文长度控制时要用到——工具调用循环跑得越多轮,input_tokens会越滚越大(因为每轮都要把历史工具调用和结果重新传一遍),这是实际接入时要注意的成本点。
五、Copilot 执行 MCP 调用
- 解析出
tool_use块,得到工具名get_weather和参数{"city": "北京"}。 - 根据工具名找到对应的 MCP Server(前面汇总工具列表时记录了 tool → server 的映射)。
- MCP Client 向该 Server 发送 JSON-RPC 请求(
id需要在本次连接会话内唯一递增,不能和之前tools/list用过的id重复,这里用2而不是继续用1):
{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"get_weather","arguments":{"city":"北京"}}}- Server 真正执行逻辑(调天气 API),返回结果。这里有两个容易被忽略的细节:
content是一个数组,可以包含多个内容块,且类型不限于文本,还可以是image、resource等:
{"jsonrpc":"2.0","id":2,"result":{"content":[{"type":"text","text":"北京今天晴,25°C"}],"isError":false}}isError字段用来区分"工具执行失败"和"JSON-RPC 请求本身失败":如果城市名传错、或者天气 API 调用异常,Server 会把isError设为true,但依然走result正常返回(不是 JSON-RPC 的error字段),并在content里描述失败原因:
{"jsonrpc":"2.0","id":2,"result":{"content":[{"type":"text","text":"找不到城市:北进(是否想输入'北京'?)"}],"isError":true}}Copilot 拿到这个结果后,不要在代码层面就中断流程,而是原样把content里的文字连同isError: true的语境一起传回 Claude,让模型自己判断要不要重试、换个参数再调一次,还是如实告诉用户查询失败了。真正的 JSON-RPC 级别错误(比如工具名根本不存在、参数不满足 schema)才会走标准的 JSON-RPCerror字段,例如{"jsonrpc":"2.0","id":2,"error":{"code":-32602,"message":"Invalid params"}},这种情况下 Copilot 需要自己处理,不能指望丢给模型能理解。
六、把结果传回 Claude,进入下一轮
Claude 要求把工具结果作为role: "user"的消息传回(注意:不是role: "tool",这点和 OpenAI 不同),并用tool_use_id对应上:
{"model":"claude-sonnet-4-6","max_tokens":1024,"messages":[{"role":"user","content":"北京今天天气怎么样"},{"role":"assistant","content":[{"type":"text","text":"我需要查一下北京的天气。"},{"type":"tool_use","id":"toolu_01AbC...","name":"get_weather","input":{"city":"北京"}}]},{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_01AbC...","content":"北京今天晴,25°C"}]}],"tools":[/* 每一轮都要带上,因为可能还要继续调用 */]}Claude 看到工具结果后,正常情况下会返回自然语言答案,本轮结束。
再补充两个容易漏掉的细节:
tool_result的content既可以是纯字符串(如上例),也可以是结构化的内容块数组,比如 MCP Server 返回的是图片,就要转成 Claude 认识的格式:
{"type":"tool_result","tool_use_id":"toolu_01AbC...","content":[{"type":"text","text":"这是查询到的图表:"},{"type":"image","source":{"type":"base64","media_type":"image/png","data":"iVBORw0KG..."}}]}- 如果 MCP 工具执行失败(即上一节提到的
isError: true),传回 Claude 时要在tool_result块上加一个is_error: true,让 Claude 明确知道这次调用是失败的,而不是把错误信息当成正常查询结果去解读:
{"type":"tool_result","tool_use_id":"toolu_01AbC...","content":"找不到城市:北进","is_error":true}七、历史消息怎么传给 Claude —— 贯穿多轮对话
这是很关键但容易被忽略的一点:Claude Messages API 是完全无状态的,服务端不会替 Copilot 记住任何上下文。每一次请求,Copilot 都必须把从对话开始到现在的完整messages数组原样带上,Claude 才能"记得"之前聊了什么。也就是说,Copilot 内部(前端状态或后端会话存储)要一直维护一份不断增长的messages数组,每次调用 API 前把它整个塞进请求体。
7.1 两种历史增长要分清楚
- 跨轮次的历史:用户问一句、Claude 答一句,这一问一答结束后会各自追加一条
user消息和一条assistant消息到数组里,这是"自然的"对话历史。 - 单轮内部因为工具调用产生的历史:前面第四、五、六节讲的
tool_use/tool_result消息,也会变成messages数组里正式的条目,并且不会在这一轮工具调用循环结束后被清除或折叠——它们会跟普通问答消息一样,永久留在历史里,被后续所有请求带着走。
换句话说,对 Claude 来说根本没有"工具调用是临时的、和普通对话历史是两码事"这种区分,tool_use、tool_result就是messages数组里普通的条目,只是content里装的内容类型不同而已。
7.2 具体例子:三轮对话,中间一轮触发了工具调用
假设用户依次说了:“你好” → “北京今天天气怎么样”(触发一次工具调用)→ “那上海呢”。到第三轮请求时,Copilot 发给 Claude 的messages数组是这样的(第二轮里工具调用产生的中间消息完整保留):
{"model":"claude-sonnet-4-6","max_tokens":1024,"system":"你是一个专业的天气助手,回答简洁。","messages":[{"role":"user","content":"你好"},{"role":"assistant","content":[{"type":"text","text":"你好,有什么可以帮你?"}]},{"role":"user","content":"北京今天天气怎么样"},{"role":"assistant","content":[{"type":"text","text":"我需要查一下北京的天气。"},{"type":"tool_use","id":"toolu_01AbC...","name":"get_weather","input":{"city":"北京"}}]},{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_01AbC...","content":"北京今天晴,25°C"}]},{"role":"assistant","content":[{"type":"text","text":"北京今天晴,25°C,适合出门。"}]},{"role":"user","content":"那上海呢"}],"tools":[/* 依然要带上,因为这一轮可能又要调用工具 */]}注意第三轮用户说的"那上海呢",这句话本身完全没提"天气"两个字——Claude 之所以能理解这是在问上海的天气、并且大概率会再触发一次get_weather工具调用,靠的就是它能看到messages数组里前面完整保留的工具调用痕迹,从上下文里推断出"那"指代的是"查天气"这件事。如果 Copilot 把第二轮工具调用的中间消息偷偷去掉、只保留最后那句自然语言总结,Claude 大概率就理解不了这种指代关系了。
7.3 工程上的实际影响:历史会越滚越大
因为工具调用产生的中间消息不会被自动清理,一个多轮、多次调用工具的对话,messages数组会越来越长,直接后果:
- 每次请求的
input_tokens会随着历史增长而增长(回顾第四节里usage.input_tokens字段),成本和延迟都会跟着涨; - 如果不加控制,总有一天会碰到 Claude 的上下文窗口上限;
- 实际接入时通常需要做历史管理策略,比如:只保留最近 N 轮完整历史、对较早的工具调用结果做摘要压缩、或者把工具调用的详细过程折叠成一句话总结再喂回去(但要清楚这样做有代价——上一节的例子已经说明,折叠得太狠会丢失 Claude 理解指代关系所需要的上下文)。
这一块没有标准答案,需要研发根据实际场景(对话轮次通常有多长、工具调用是否频繁、成本预算)去权衡,MCP 和 Claude API 都不会替你做这个决定。
八、Copilot 怎么判断"彻底不需要调用工具了"
这一步不靠语义分析、不靠猜测,而是靠一个明确的字段:stop_reason。
8.1 stop_reason 的可能取值
| stop_reason | 含义 | Copilot 应该怎么做 |
|---|---|---|
end_turn | 模型认为回答完整了 | 循环结束,把 content 中的 text 展示给用户 |
tool_use | 模型需要调用一个或多个工具 | 执行 MCP 调用,把结果传回去,继续循环 |
max_tokens | 输出撞到长度上限被截断 | 视情况处理,可能需要继续请求补全 |
stop_sequence | 命中自定义停止词 | 结束 |
pause_turn | 长时间任务中途暂停 | 通常把上次响应原样发回,让它接着跑 |
refusal | 模型出于安全原因拒绝 | 结束,按拒绝处理 |
8.2 Copilot 判断逻辑(伪代码)
asyncfunctionchatLoop(messages,tools){while(true){constresponse=awaitcallClaude(messages,tools);if(response.stop_reason==="tool_use"){consttoolCalls=response.content.filter(b=>b.type==="tool_use");// 把这轮 assistant 消息(含文字+工具调用请求)塞回历史messages.push({role:"assistant",content:response.content});// 依次执行每个工具调用consttoolResults=[];for(constcalloftoolCalls){// mcpResult 是 MCP tools/call 返回的 result 对象:{ content, isError }constmcpResult=awaitcallMcpServer(call.name,call.input);toolResults.push({type:"tool_result",tool_use_id:call.id,content:mcpResult.content,// 原样透传给 Claudeis_error:mcpResult.isError===true// MCP的isError映射成Claude的is_error});}// 所有结果打包成一条 user 消息回传messages.push({role:"user",content:toolResults});continue;// 继续下一轮}if(response.stop_reason==="end_turn"){constfinalText=response.content.filter(b=>b.type==="text").map(b=>b.text).join("");returnfinalText;// 结束,展示给用户}returnhandleOtherStopReason(response);}}8.3 关键注意事项
stop_reason是唯一权威信号,不需要分析文本内容判断"是否说完了"。content里可能同时有 text 和 tool_use:即使模型说了"我需要查一下",只要stop_reason是tool_use,就必须继续执行工具,不能因为看到文字就误判为结束。- 必须设置最大循环次数保护(如 10 轮),防止异常情况下模型反复调用工具、程序陷入死循环。
- Claude 支持一次返回多个并行的
tool_use块(比如同时查天气和查日历),此时必须把每个工具的结果收集齐,一次性打包成一条user消息回传,不能拆开发送,否则会因缺少某个tool_use_id对应结果而报错。
九、整体流程图
【启动阶段】 MCP Server --tools/list--> Copilot(汇总工具schema,缓存内存) 【对话循环】 用户提问 → Copilot 发起请求(messages + tools) → Claude 判断是否需要工具 ├─ stop_reason = tool_use │ → Copilot 解析 tool_use 块,匹配对应 MCP Server │ → MCP Server 执行(tools/call) │ → 结果按 tool_use_id 打包成 role:user 的 tool_result 消息 │ → 回传给 Claude,重新进入判断 │ └─ stop_reason = end_turn(或其他终止态) → 循环结束,取出 text 内容展示给用户十、一句话总结
- MCP 标准化的是Host ↔ Server这一段(工具怎么注册、怎么被调用)。
- Host ↔ LLM这一段走的是各家官方的 tool use API(Claude 是 Messages API 规范,非民间约定,但各厂商之间不互通)。
- 工具"该不该调、调完了没有"完全由模型通过
stop_reason字段明确告知,Copilot 只需读这一个字段做分支判断,不需要做语义理解。 - Claude API无状态,历史(包括普通问答和工具调用产生的中间消息)全部要由 Copilot 自己维护,每次请求整个
messages数组原样带上;工具调用留下的痕迹会永久留在历史里,这也是后续对话能理解指代关系的前提。
