Skip to main content

Agent怎么发现并调用MCP工具

版本说明:为了减少架构复杂度,Ragent2.0代码本次发布后,Ragent1.0对应的Workflow链路不再保留MCP调用,RAG回到知识库检索与问答本身。查订单、查物流等业务工具统一由Agent调用,避免在两条链路中同时维护工具连接、参数提取和结果处理。历史版本曾经接入过MCP,下面按重构后的代码展开。

上一篇沿着一次提问走到了模型输出,也留下了一个还没拆开的地方:模型决定查订单时,那个能查订单的工具,是怎么出现在它面前的?

换一句比特严选用户可能都会问的话:

上周买的AirPods Pro还没收到,帮我看看送到哪了。

用户没有提供订单号,也没有提供运单号。正常情况下,Agent应该先查询当前账号最近的订单;如果查到多笔耳机订单,就让用户确认具体是哪一笔。订单确定后,再从订单信息中拿到运单号,继续查询物流轨迹,最后告诉用户包裹现在到了哪里,而不是泛泛地回复“通常几天可以送达”。

这就是一条典型的T2链路:后一次工具调用的入参,依赖前一次工具调用的结果。只有先查到订单,Agent才知道下一步该拿哪个运单号去查物流。

真正接入时,问题常常出在别处。订单服务是正常的,物流工具也已经发布,可用户问物流进度时,Agent却始终没有查询订单,而是在解释平台通常需要几天送达。你甚至在系统提示词里明确补了一句“查询物流前先查询订单”,结果它还是没有调用订单工具。

这时先别怀疑模型有没有读懂提示词,更可能的情况是这一轮模型拿到的工具清单里压根就没有订单查询工具。模型看不见的工具,提示词写得再明确也调不动。

可MCP Server返回的工具列表里,订单工具明明存在。问题就落在两者之间:从MCP Server到模型并不是一条直通链路,服务端能够提供哪些工具、应用实际接入了哪些工具、经过筛选和组装后这一轮又向模型暴露了哪些工具,中间还隔着多层处理。所以只确认MCP Server接口能调通,并不代表工具调用链路已经验证完成,真正需要确认的是这个工具有没有出现在当前这一轮模型实际可见的工具集合里。

模型知道工具,靠的是清单​

1. 业务接口已经有了,为什么还要MCP​

订单查询原本就可以是一个HTTP接口。把它包进Java方法,再把方法声明交给模型,完全能用。工具不多、调用方只有一个时,这条路很合适,没有必要为了用协议再绕一层。

麻烦出在接口数量和使用方一起增长以后。

查订单用一个地址,查物流换一套参数格式,售后服务又有自己的错误码。每增加一个工具,Agent侧都要知道它怎么调用、怎么解释返回、怎么把参数说明翻译成模型能用的格式。换一个Agent框架,适配代码还可能再写一份。

MCP(Model Context Protocol,模型上下文协议)把工具发现与调用的外层约定统一起来。提供方声明工具叫什么、做什么、接收什么参数;使用方按协议拉清单,再按工具名发起调用。业务查询和业务校验仍然由提供方实现,协议不会替你写订单系统。

在协议层,tools/list用来列工具,tools/call用来调用工具,工具定义里包含名称、描述和输入Schema。具体字段可以对照 MCP工具规范,或者查看咱们之前讲过的MCP相关章节。

几个容易混在一起的职责,可以先分开看:

这一层负责什么不负责什么
业务工具查订单、查物流、校验归属,返回真实结果决定整段对话下一步往哪走
MCP客户端与服务端交换工具定义,传递调用与结果理解用户这次究竟想办什么
Agent框架把定义交给模型,接住工具调用,执行后继续循环自动补齐业务规则和身份来源
模型根据问题、历史和工具说明决定调用自己建立网络连接、直接访问业务库

所以,日常说的模型发现MCP工具,是个省略说法。真正连接MCP Server、请求工具列表的是应用里的客户端。模型看到的是应用整理后交给它的工具定义。

2. 工具接进循环之后,远近不影响用法​

对模型来说,查知识库和查订单可以出现在同一份清单里。

search_knowledge在当前应用进程里进入RAG检索链路;query_order要经过MCP客户端去另一个服务查询订单。它们底下走的路不同,交给模型的形状却相同:工具名、用途说明、参数结构。

模型输出一次工具调用,框架找到对应的执行对象,执行完把结果放回上下文。模型再看结果,决定继续查还是回复用户。Function Calling,也就是模型生成结构化工具调用的能力,解决的是模型与宿主应用之间的这一段;MCP解决的是宿主应用与工具服务之间的那一段。

这里也没有要求所有工具都远程化。已有的知识检索门面就在进程内,再包一层网络服务会增加部署和故障处理的负担。Ragent保留本地工具,同时用MCP接入独立的业务工具。

3. 为什么这次改用AgentScope封装后的MCP​

Ragent1.0没有采用Agent框架,MCP接入直接使用官方Java SDK。客户端怎么连接、工具清单怎么保存、参数怎么提取、调用结果怎么接回问答流程,都由项目自己组织。

到了2.0,AgentScope已经提供了MCP客户端、工具定义转换和结果转换能力。既然Agent执行链路已经交给框架,这些通用工作也可以一并复用。继续维护原来的实现,就要在项目里重复处理连接、注册和适配,后续升级时还得分别检查。

因此,当前代码直接使用AgentScope的MCP封装,并将MCP调用统一放到Agent侧。RAG原有的MCP客户端、参数提取器和执行器注册表已经移除,检索链路只处理知识库内容。意图树里的MCP节点仍然保留,供Agent选择工具、配置描述和确认策略使用。

依赖也随之统一:当前使用AgentScope 2.0.2,项目MCP服务端与框架客户端采用相同的MCP Java SDK 0.17.0。这里是Java SDK的版本,不是MCP协议版本。客户端和服务端按这组依赖接入,避免项目单独指定另一版SDK后,框架内部调用出现兼容问题。

代价是框架的默认行为也一起接了过来:启动时会多拉一次工具清单,MCP结果转换后默认是RUNNING,MCP工具被标成不能并发,父类callAsync()的异常出口也不合用。下文会逐个讲到,其中状态和异常出口项目补了代码,另外两处按现状接受。

项目仍然保留了必要的接入代码。AgentMcpClients管理框架客户端和发现到的工具,McpToolProxy补充写操作确认、技能检查、执行记录和停止处理。框架负责通用的MCP能力,业务上的执行规则由Ragent自己掌握。

从服务端声明,到客户端注册​

1. 工具说明要交代参数从哪里来​

订单号用来找到商城中的订单;运单号就是快递单号,用来查询包裹的配送轨迹。项目把这两种查询分成了query_order和query_logistics两个工具。

用户问“上周买的AirPods Pro送到哪了”,Agent要先调用query_order找到那笔订单,从结果里取出运单号,再调用query_logistics查配送轨迹。用户给了订单号,也不能直接拿它当运单号。

物流工具的说明要交代这层关系:需要运单号,从订单查询结果里取。BitLogisticsQueryMcpExecutor的buildTool()将它写进了定义:

private Tool buildTool() {
JsonSchema inputSchema = McpToolSchema.object()
.required(string("trackingNo", "运单号,如 BIT9100000233,从订单查询的结果里取"))
.build();

return Tool.builder()
.name(TOOL_ID)
.description("按运单号查询物流轨迹,返回对应订单号、订单状态与全部轨迹节点,由新到旧。"
+ "运单号先用订单查询取到再传进来,不要凭用户口述或自行拼接")
.inputSchema(inputSchema)
.annotations(McpToolAnnotations.READ_ONLY)
.build();
}

只写“查询物流”,模型可能知道该选哪个工具,却不知道运单号从哪里取,反而让用户自己找快递单号。

buildTool()声明工具怎么用,handleCall()负责实际查询。下面的Spring Bean把它们绑定起来,让服务端知道收到这个工具的调用后该执行哪个函数:

@Bean
public McpServerFeatures.SyncToolSpecification queryLogisticsToolSpecification() {
return McpServerFeatures.SyncToolSpecification.builder()
.tool(buildTool())
.callHandler((exchange, request) -> handleCall(request))
.build();
}

在mcp-server模块中,McpServerConfig收集这些定义与处理函数,通过/mcp提供工具发现和调用入口。新增工具需要服务端编写实现,Agent侧可以沿用同一套MCP客户端。

buildTool方法中有个READ_ONLY,用来声明工具只查询、不修改数据,参与调用前的确认判断。项目里的工具必须显式声明readOnlyHint,否则服务端启动检查会失败。具体怎样与确认配置配合,留到人机确认那部分展开。

2. 连接归Agent管,客户端使用框架封装​

当前MCP连接配置已经移到agent.mcp.servers。AgentMcpProperties读取它,AgentMcpClients负责创建连接、发现工具和关闭连接。application.yaml里的配置如下:

agent:
mcp:
servers:
- name: default
url: http://localhost:9099

AgentMcpClients带有@ConditionalOnAgentEngine,只在Agent模式下创建。切回Workflow,即使配置文件里留着MCP地址,也不会创建这套客户端。

2.1工具定义与客户端一起保存​

AgentMcpClients在启动时按配置顺序连接各个服务。下面是connect()中的连接与发现部分:

String baseUrl = StrUtil.removeSuffix(server.getUrl(), "/");
String url = baseUrl.endsWith("/mcp") ? baseUrl : baseUrl + "/mcp";
client = McpClientBuilder.create(server.getName())
.streamableHttpTransport(url)
.buildSync();
client.initialize().block();
List<Tool> discovered = client.listTools().block();
clients.add(client);

McpClientBuilder来自AgentScope,底下仍使用官方MCP Java SDK。这里选择Streamable HTTP传输和同步客户端,由框架用响应式接口包装调用,与聊天回答使用的SSE属于不同链路。

初始化阶段先协商版本和能力,再进入正常工具调用,具体过程见 MCP生命周期规范。当前框架还会在initialize()内部拉取并缓存工具;项目随后显式调用listTools(),拿到构建本地映射所需的列表。因此,这段启动过程实际不只请求一次工具清单。

发现到的每个工具,都会和它所属的客户端放在一起。AgentMcpClients用这个记录保存关联:

public record RemoteTool(Tool definition, McpClientWrapper client) {
}

模型只给出工具名与参数,请求该发往哪个MCP Server,靠这里记下的客户端来定。

2.2启动失败后,工具不会凭空补回来​

应用启动时,会按配置依次连接各个MCP服务。某个服务连不上,connect()就记录错误,随即尝试关闭这次创建的客户端,然后接着连下一个。应用不会因此停止启动,知识库和其他已经接入的工具仍然能用,只是Agent的工具清单里会缺少这个服务提供的工具。

本地工具映射在启动时建立,没有定时重新发现,也没有接入远端工具变更通知。服务后来恢复或发布新工具,都不会自动补进目录。当前代码没有提供重新发现的入口,只能重启应用。

在控制台修改订单工具的描述,后续请求可以用上新描述。但如果是在MCP服务端新增工具,或者修改工具的参数,应用并不会自动获取这些变化。即使重新创建了Agent,用的也还是启动时保存的那份工具清单和参数定义,因为创建Agent的过程不会再次向MCP服务请求工具列表。

应用关闭时,AgentMcpClients统一关闭持有的客户端。连接的创建、归属和释放都留在Agent这一侧,RAG不再参与这段生命周期。

工具已经发现,为什么模型还是看不到​

1. 主Agent只接入配置与远端工具的交集​

在Ragent项目中可以配置多个MCP Server,单个MCP Server又可以提供很多工具,但这些工具当前助手未必要全部使用。

比特严选只需要电商业务工具,服务端还可能保留另一套业务域的能力。如果把远端清单原样交给模型,部署时只想接入一个订单服务,结果连与当前助手无关的功能也一起暴露了。工具名字再清楚,也会占用模型输入,增加选择时的干扰。

Ragent用意图树决定主Agent要挂哪些MCP工具。IntentNodeRegistry的listMcpToolNodes()返回MCP类型叶子节点。AgentToolCatalog把它们按工具标识分组,再向AgentMcpClients查找同名远端工具。只有配置和发现结果两边都存在,才生成可挂载的绑定。

resolveMcpTools()里,交集发生在这段代码中。前面已经准备好按工具分组的节点,下面是逐项查找与绑定的原文:

List<McpToolBinding> bindings = new ArrayList<>();
List<String> unavailableToolIds = new ArrayList<>();
nodesByToolId.forEach((toolId, nodes) -> {
RemoteTool remote = mcpClients.get(toolId);
if (remote == null || RESERVED_TOOL_NAMES.contains(toolId)) {
unavailableToolIds.add(toolId);
return;
}
bindings.add(toBinding(toolId, nodes, remote));
});
bindings.sort(Comparator.comparing(McpToolBinding::toolId));
unavailableToolIds.sort(Comparator.naturalOrder());

回到开头那个一直解释配送时效的Agent。即使服务端已经发布query_order,如果意图配置没有挂这个工具,主Agent依然拿不到它。反过来,控制台配好了,启动时没连接上服务端,没有对应的远端工具,也进不了清单。

排查时可以顺着这张表逐层缩小范围:

当前状态会发生什么
已发现远端工具,本地没有对应MCP意图叶子节点定义和客户端关联已保存,但主Agent不挂载
本地配置了工具,发现结果里没有同名工具记入不可用工具列表,构建Agent时打印警告
配置与远端工具都有,名称没有占用保留名生成绑定,进入Toolkit
配置与远端都有,但被某份未加载的技能手册列为解锁工具已注册进Toolkit,当轮不把Schema提供给模型,直到那份手册被load_skill加载

最后一行解释了另一种“明明注册了却看不到”。工具能不能被执行、这轮要不要展示给模型,是两层控制。哪些工具要等手册加载后才出现,由技能配置里列的工具决定,和是否需要确认无关,细节属于按需加载手册那部分的内容。

取交集的代价也很直接:增加远端工具后,还要补应用配置。少做一步不会自动补齐,通常表现为清单缺项。换来的是应用能明确决定自己接入哪些能力,而不是跟着远端每次发布一起扩张。

解锁付费内容,👉戳​