MCP 工具调用机制详解 MCP 工具调用机制详解本文档整理自与 Claude 的技术沟通面向研发同学讲清楚Host 程序如 Copilot如何驱动大模型调用 MCP 工具这一完整链路并以 Claude 官方 API 为例给出真实的 JSON 格式。一、角色定义MCPModel Context Protocol定义了三方关系角色说明Host宿主程序面向用户的应用例子中的 CopilotMCP Client内嵌在 Host 里负责和某一个 MCP Server 建立连接一对一MCP Server独立进程/服务暴露一批工具tools比如查天气、读文件、发邮件关键认知MCP 只规定 Host 与 Server 之间怎么通信不规定Host 与 LLMClaude/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 上工具很多一次返回不全会带nextCursorClient 需要拿这个游标再发一次tools/list请求翻页直到响应里不再带nextCursor为止。Copilot 把所有 Server分页翻完后返回的 tools 汇总成一个列表缓存在内存里等待对话时使用。三、对话阶段请求怎么发给 Claude3.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确实没有统一标准字段名不完全一样这是行业收敛出的相似模式不是同一份规范OpenAIClaude工具字段名tools[].function.parameterstools[].input_schema工具包裹层多一层type: function无包裹直接平铺触发原因字段finish_reason: tool_callsstop_reason: tool_use工具结果角色role: toolrole: 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){constresponseawaitcallClaude(messages,tools);if(response.stop_reasontool_use){consttoolCallsresponse.content.filter(bb.typetool_use);// 把这轮 assistant 消息含文字工具调用请求塞回历史messages.push({role:assistant,content:response.content});// 依次执行每个工具调用consttoolResults[];for(constcalloftoolCalls){// mcpResult 是 MCP tools/call 返回的 result 对象{ content, isError }constmcpResultawaitcallMcpServer(call.name,call.input);toolResults.push({type:tool_result,tool_use_id:call.id,content:mcpResult.content,// 原样透传给 Claudeis_error:mcpResult.isErrortrue// MCP的isError映射成Claude的is_error});}// 所有结果打包成一条 user 消息回传messages.push({role:user,content:toolResults});continue;// 继续下一轮}if(response.stop_reasonend_turn){constfinalTextresponse.content.filter(bb.typetext).map(bb.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 APIClaude 是 Messages API 规范非民间约定但各厂商之间不互通。工具该不该调、调完了没有完全由模型通过stop_reason字段明确告知Copilot 只需读这一个字段做分支判断不需要做语义理解。Claude API无状态历史包括普通问答和工具调用产生的中间消息全部要由 Copilot 自己维护每次请求整个messages数组原样带上工具调用留下的痕迹会永久留在历史里这也是后续对话能理解指代关系的前提。