这一章我拖得有点久。前面几章我们在讲 AgentScope-Java 的消息流转、Pipeline 编排和多智能体协作时,工具调用还处在“框架自己封装 Function Calling”的阶段。但这半年我再做实际项目,发现一个新趋势:新工具几乎都朝着 MCP 协议靠拢,尤其是企业内部想统一接入 AI 能力的时候,MCP 几乎成了默认选项。既然这个系列叫深入浅出,那么 MCP 协议集成这一环就不能只靠官方仓库里的 readme 糊弄过去,必须单独拎出来讲透。
这篇是《AgentScope-Java 深入浅出教程》的第九章。我会从 MCP 到底在解决什么问题开始,然后落在 AgentScope-Java 里怎么设计工具注册、怎么建立连接、怎么把远端 MCP 工具变成模型能调用的函数,再把生产环境中容易踩的坑全部摆出来。如果你之前只接触过给模型直接配 function 列表的方式,这章会帮你理解为什么社区都在往 MCP 上迁移。
1. 为什么 Agent 框架最终都要走向标准化工具协议
1.1 一个“没有 MCP 之前”的工具接入现场
我最早在 AgentScope-Java 里做工具接入时,方式非常原始。比如对接一个内部订单查询服务,我先根据它的 HTTP 接口写一个 Java 方法,然后手动定义一个工具描述,把参数 schema 写好,再注册到 Agent 的工具表里。等第二个服务接进来,同样的事情再来一遍。看起来不难,但量一上来就变味了。
我们曾经在一个项目里接了十几个工具,每个工具都有自己的一套鉴权方式,有的是 Header 里塞 Token,有的是签名参数,有的是 OAuth2;返回结构也不统一,有的是 JSON,有的是 XML,有的把业务信息包了一层又一层。结果就是每接入一个新工具,我都要写一遍“参数解析 + 鉴权 + 错误转换 + 返回结构化”。这些代码高度重复,但又没法完全抽象,因为每个服务的细节都不一样。
后来我把这类代码统一收敛到一个叫ExternalToolAdapter的抽象里,试图减少重复。但适配器本身又变成了新的维护负担。真正让我决定切换思路的,是一个很直观的数据:Agent 的能力边界,取决于它能不能稳定调用足够多的外部工具;而工具接入的边际成本如果降不下来,Agent 就永远只能在小范围玩具场景里打转。
1.2 MCP 解决的是接口标准化问题
MCP 全称是 Model Context Protocol,它做的事情很像给 AI 应用的外设接口制定了一个统一标准。在没有 MCP 之前,一个 Agent 要接数据库工具,要接搜索工具,要接企业内部 OA 工具,相当于每一类外设都要单独的驱动;有 MCP 之后,工具提供方只要实现一个 MCP Server,Agent 侧通过 MCP Client 去连接,就能以同一套协议完成“发现工具、查看参数、发起调用、拿到结果”的完整流程。
用生活化的比喻:以前你出门要带各种设备的充电线,MCP 就是想做成 USB-C 口。它不规定你这个设备本身长什么样、性能多强,只规定“供电、数据传输、握手”这些交互规范。所以你仍然可以写各种业务逻辑复杂的工具,但对外暴露的接口形态是统一的,客户端不需要为每个工具单独写一套接入逻辑。
对 AgentScope-Java 这类 Java 框架而言,这个标准化的价值尤其明显。Java 生态里本身就有大量成熟的企业服务,如果每个服务都要为 Agent 重新定制封装,工程量非常大;但如果企业内部的服务都能通过 MCP Server 暴露出来,Agent 侧只需要写一套通用的 MCP 适配层,剩下的事情就是“连接哪个 Server、开放哪些工具”的配置问题。
1.3 谁适合看这章,以及需要什么前置知识
如果你正准备在 AgentScope-Java 项目里接入外部工具,或者你手头已经有一个 MCP Server,但不知道如何在 Java 智能体框架里使用它,那这章就是给你准备的。我不会假设你已经很熟悉 MCP 的底层规范,但默认你了解 AgentScope-Java 的基本概念:Agent、Tool、消息循环这些。如果你只写过 Python 的 MCP 客户端,这章也可以帮你补齐 Java SDK 侧的实操差异。
2. MCP 协议的关键机制,先讲清楚再写代码
2.1 Host、Client、Server 各自管什么
MCP 协议的官方文档把参与方分成三层:Host、Client、Server。很多人刚接触时会把 Host 和 Client 混为一谈,其实它们是有明确分工的。
Host 是最终的使用环境,在 AgentScope-Java 里可以理解为你整个 Agent 应用。它是用户交互的入口,负责决定“什么时候需要调用工具”“把模型返回的结果怎么呈现给用户”。Client 是协议层面的引擎,它运行在 Host 进程内部,负责与远程的 MCP Server 建立连接、维持会话、收发 JSON-RPC 消息。Server 则是工具的实际提供方,它可能是一个本地子进程,也可能是一个远程 HTTP 服务。
打个比方,如果你把 Agent 应用比作一个餐厅,Host 是餐厅整体经营,Client 是服务员,Server 是后厨。服务员不决定客人点什么菜,但负责把菜单拿给客人、再把客人的订单传到后厨、最后把菜端上桌。在 MCP 体系里,AgentScope-Java 的模型决策逻辑决定要不要用某个工具,但这个工具具体怎么被调用、参数怎么传过去,是 Client 和 Server 之间的协议交互。
2.2 初始化握手与 JSON-RPC 通道
MCP 底层通信基于 JSON-RPC 2.0,消息格式非常规整。一次工具调用大概是这样的请求:
{ "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "get_current_time", "arguments": { "timezone": "Asia/Shanghai" } } }但在真正开始工具调用之前,Client 和 Server 必须先完成一次初始化握手。Client 发送initialize请求,告诉 Server 自己支持的协议版本和客户端能力,Server 返回自己支持的版本和服务端能力。如果两边协议版本差异过大,服务端会返回一个兼容版本号,由客户端重新发起握手。握手成功之后,客户端还需要发送一个notifications/initialized通知,表示“我已经准备好接收服务端的主动消息了”。
我在最初调试时忽略过这个握手细节,直接去调tools/list,结果被服务端拒绝。所以如果你的 AgentScope-Java 集成里出现了“工具列表拉取不到”的情况,先检查是不是没有完成初始化,不要直接怀疑网络和 Server 进程。
2.3 Tools、Resources、Prompts 三类能力的边界
MCP 协议定义了三个核心能力,很多人容易混:Tools、Resources 和 Prompts。
Tools 是最常用的一类,它是可执行的动作,有输入参数,会改变外部状态或返回计算结果。比如“查天气”“创建工单”“执行代码”,这类能力适合让模型根据用户意图自主决定调用。Resources 是只读的数据资源,比如文件内容、数据库记录、项目文档,它强调的是“获取数据”,而不是“执行动作”。Prompts 则是预定义好的提示词模板,为的是让客户端能够复用一套标准化的 prompt。
在 AgentScope-Java 里,我们主要消费的是 Tools 这一类能力。但如果你接入的 MCP Server 同时暴露了 Resources,你也可以把它们当成上下文检索来源。比如让 Agent 在回答某个问题时,先通过resources/read去读取一段服务器端文档,再把内容拼到对话上下文中。协议本身没有限制只能消费 Tools,只是按 Tools 方式接入对模型最自然。
| 能力类型 | 典型用途 | Agent 侧的价值 |
|---|---|---|
| Tools | 动作执行:查询、写入、计算、操作 | 模型自主决策调用 |
| Resources | 只读数据:文件、文档、配置 | 补充上下文、供检索 |
| Prompts | 固定提示词模板 | 让交互更标准化 |
2.4 stdio 与 Streamable HTTP 怎么选
MCP 的传输层主要有两种形态。stdio 模式适合把 MCP Server 作为客户端的一个子进程来运行,通信走标准输入输出。好处是部署简单,不需要额外开放端口,进程生命周期可以由客户端管理;坏处是 Server 通常只能被一个客户端进程使用,不适合跨机器共享,也不适合分布式的 Agent 服务。
Streamable HTTP 模式实际上是远程 HTTP 服务,Agent 服务作为 HTTP 客户端去访问 MCP Server。这样 Server 可以独立部署在多台机器上,客户端按需发起请求。对 AgentScope-Java 这种经常以后端服务形式运行的应用来说,我更推荐远程 HTTP 方式,因为 Agent 本身可能就跑在微服务中,不可能为了拉一个本地文件工具再单独孵化一个子进程。
选择传输方式时不要只看名字,还要看 MCP Java SDK 的版本支持。新版里更推荐 Streamable HTTP,老版本里常见的是 SSE 方式。代码层面两者差不多,只是 transport 的构建方式不同,这个下一章实操部分会展开。
3. AgentScope-Java 的工具抽象与 MCP 的映射关系
3.1 AgentScope-Java 是怎么让模型使用工具的
AgentScope-Java 在工具调用这条链路的设计,和其他主流 Agent 框架大同小异。你先把一批工具注册到框架里,框架将每个工具的描述、参数信息转成模型能够理解的 schema,并在每次调用模型时把 schema 一起传给模型。模型在生成回复时,如果判断某个任务需要查外部信息或执行某个动作,会输出一个结构化的工具调用指令,而不是直接输出自然语言。
框架收到这条指令之后,根据工具名称找到对应的执行器,把参数传进去执行,然后把执行结果作为一条新的消息回传给模型,让模型基于真实结果继续组织语言。这样一个循环被称为 Agent 的工具调用循环。
在这个机制里,工具描述的质量、参数 schema 的准确程度,直接决定了模型能不能正确选用工具。很多接入 MCP 后效果不佳的情况,并不是 MCP 协议出了问题,而是工具描述没有写好,导致模型理解不了每个工具到底是干什么的。
3.2 为什么按“单个 MCP 工具”注册而不是按“整个 Server”注册
我记得刚开始集成时,团队里有个同事提出过一个疑问:既然一个 MCP Server 已经是一个功能集合了,那能不能把整个 Server 抽象成一个 Agent 工具,让模型调用时自己填“要调用 Server 里的哪个子功能”?我建议不要这么做。
原因有两个。第一,模型的工具选择依赖工具描述和参数 schema,如果把整个 Server 压成一个工具,参数里就要嵌套“子工具名”和“子工具参数”,模型选择时有很大的不确定性。第二,错误处理会变得复杂,如果 Server 内部没有找到对应子工具,错误信息很容易在链路中的某一层被吞掉。
正确姿势是:通过 MCP Client 的tools/list拿到 Server 暴露的每一个工具,然后逐个映射成 AgentScope-Java 的独立 Tool 注册。这样在模型侧,每个工具都是平级且描述清晰的;在 AgentScope-Java 侧,每个工具又共享同一个远端的 MCP Client 通道。
3.3 一次集成涉及的四个生命周期阶段
第一个阶段是“连接”。根据你选择的传输方式,创建 MCP Client,与 Server 建立底层通信通道。第二个阶段是“握手”。Client 发送initialize,完成协议版本协商,然后发送 initialized 通知。第三个阶段是“发现与注册”。调用tools/list获取全部工具信息,转换成框架的 ToolDefinition 并注册到 Agent 的工具注册中心。第四个阶段是“调用与运维”。Agent 运行过程中按需调用tools/call,同时你要处理连接断开、工具列表刷新等后续问题。
很多教程只讲前三个阶段,忽略第四个阶段。但真实生产环境里,一个 MCP Server 不可能是永远稳定的。它可能会升级、会重启、会超时,甚至会在运行过程中新增工具。如果你只在应用启动时做一次工具发现,那之后 Server 端新增的能力就无法被 Agent 感知到。所以在设计集成方案时,建议把“工具刷新”也考虑进去,可以通过 TTL 缓存定期刷新工具列表,也可以提供管理接口手动触发刷新。
4. 实操:从零接入一个 MCP Server
这一章开始写代码。为了让整个流程可以完整跑通,我建议先自己写一个非常简单的 MCP Server,充当 Agent 侧要对接的目标。等跑通之后再替换成真实的业务 Server。
4.1 写一个本地 MCP Server 用来联调
我用 Java MCP SDK 写了一个最小可用的 HTTP 版 MCP Server,对外暴露一个get_current_time工具。这样做的目的是联调时你可以完全把控 Server 的行为,出了任何问题都能在自己的代码里排查。
<dependency> <groupId>io.modelcontextprotocol.sdk</groupId> <artifactId>mcp</artifactId> <version>0.10.0</version> </dependency>Server 端代码如下,核心是利用McpServer.builder注册工具和处理函数:
public class DemoMcpServer { public static void main(String[] args) { var transport = HttpMcpTransport.builder() .port(8080) .path("/mcp") .build(); var server = McpServer.builder(transport) .tool( new McpSchema.Tool( "get_current_time", "Get current time at specified IANA timezone, for example Asia/Shanghai", """ { "type": "object", "properties": { "timezone": { "type": "string", "description": "IANA timezone name" } }, "required": ["timezone"] } """ ), (exchange, request) -> { String timezone = request.arguments().get("timezone").asText(); String result = "Current time: " + java.time.ZonedDateTime.now(java.time.ZoneId.of(timezone)); return Mono.just(McpSchema.CallToolResult.success( new McpSchema.TextContent(result) )); } ) .build(); server.start().block(); System.out.println("MCP Server started at http://localhost:8080/mcp"); Thread.currentThread().join(); } }需要注意,MCP Java SDK 的 API 在 0.x 版本中变化比较频繁,比如HttpMcpTransport在部分版本里叫别的名字。如果你发现类名或方法名对不上,不要慌,去 SDK 的 release notes 里查变更即可,整体思路是不变的。
4.2 在 AgentScope-Java 里创建 MCP Client 并初始化
Agent 侧要创建一个 MCP Client。如果按照 AgentScope-Java 推荐的模块化方式来写,我一般会把 MCP 客户端封装成一个McpToolConnector,避免把 SDK 细节散落在各个业务代码里。
McpTransport transport = HttpMcpTransport.builder() .url("http://127.0.0.1:8080/mcp") .build(); McpClient mcpClient = McpClient.builder(transport).build(); // 初始化握手 mcpClient.initialize().block(Duration.ofSeconds(10));这里有个需要注意的坑:initialize方法返回的是 Reactor 的Mono,如果在没有配置阻塞超时的情况下直接.block(),一旦服务端没有响应,你的应用启动线程会一直卡住。所以务必给 block 操作加上超时时间。
关于握手后的initialized通知,不同版本的 SDK 处理方式不太一样。有些版本会自动发送,有些版本需要手动调用。如果你在调用tools/list时遇到协议状态错误,第二件事就是去查你用的 SDK 版本是否已经自动完成通知发送。
4.3 将远端 Tool 转成 Agent 工具并注册
初始化完成后,下一步是拉取 Server 的工具列表,并将其注册到 AgentScope-Java 的工具注册中心。假设 AgentScope-Java 里的工具注册中心接口是toolRegistry.register(...),我们可以这样写:
ListToolsResult listResult = mcpClient.listTools(null).block(Duration.ofSeconds(10)); for (McpSchema.Tool mcpTool : listResult.tools()) { toolRegistry.register(ToolDefinition.builder() .name(mcpTool.name()) .description(mcpTool.description()) .inputSchema(parseSchema(mcpTool.inputSchema())) .executor(args -> invokeMcpTool(mcpClient, mcpTool.name(), args)) .build()); }这段代码本质上是做了一个“翻译层”:MCP Server 的工具描述是 MCP Schema 格式,AgentScope-Java 的 Tool 定义需要的是内部统一的 schema 格式。两者基本都是 JSON Schema,所以大多数情况下不需要深层转换,只需要做一次 JsonNode 的浅层封装。
executor里做的事情也很简单:接收 Agent 侧解析好的参数,调用 MCP Client 的callTool方法,把远端执行结果转换成框架可以返回给模型的结构。写到这里你会发现,真正的衔接点就是这么薄薄一层,MCP 的标准化带来的收益就在这里体现出来了。
4.4 从模型决策到 MCP 返回的完整调用链
我们以一个用户问题为例:用户问“现在上海时间几点?”完整链路是这样的。
用户消息进入 Agent,框架调用 LLM 时把已注册工具的描述和 schema 传给模型。模型发现需要调用get_current_time工具,于是输出工具调用指令,包含工具名和参数{"timezone": "Asia/Shanghai"}。AgentScope-Java 的工具执行器收到指令,在注册表中找到对应的 ToolDefinition,调用executor。executor内部把参数转发给 MCP Client,MCP Client 将调用封装成 JSON-RPC 请求发送给 MCP Server,等待响应。MCP Server 执行成功后返回CallToolResult,包含一行文本描述。最后 execuotor 把文本作为工具执行结果返回给 AgentScope-Java,框架把结果包装成工具消息回传给 LLM,LLM 基于结果生成最终回复。
每一条链路都清晰可追踪,这是我们选择 MCP 的额外好处。以前接自研 HTTP 工具时,每个工具的链路都不太一样,调试全靠经验;现在所有 MCP 工具都遵循同一条路径,出了问题只要检查路径上的几个关键点就行。
private JsonNode invokeMcpTool(McpClient mcpClient, String toolName, JsonNode args) { CallToolResult result = mcpClient.callTool( new CallToolRequest(toolName, toObjectNode(args)) ).block(Duration.ofSeconds(30)); if (result.isError()) { // 业务上的失败不要直接抛异常,把错误文本返回给模型,让它自行修正 return TextNode.valueOf(String.format("Tool %s call failed: %s", toolName, extractErrorMessages(result))); } StringBuilder sb = new StringBuilder(); for (McpSchema.Content content : result.content()) { if (content instanceof McpSchema.TextContent textContent) { sb.append(textContent.text()); } // 如果 Server 返回了图片等其他类型,你需要根据 Agent 的上下文能力决定如何处理 } return TextNode.valueOf(sb.toString()); }在写这个方法时,有一个细节值得特别说明:很多人在 MCP Server 返回isError=true时会把异常直接往上抛。这在普通 API 调用里没问题,但在 Agent 工具循环里很容易导致整个会话中断。更好的做法是把错误信息作为文本返回给模型,让模型有机会修正参数或向用户解释。比如工具返回了“参数 timezone 不合法”,模型看到之后会重新生成一次更合理的参数。
4.5 善后:连接关闭、重连与工具刷新
MCP Client 底层可能维护着 HTTP 连接池或子进程对象,如果应用关闭时不释放,会造成连接泄漏。所以一定要在应用生命周期结束时调用 MCP Client 的 close 方法。McpClient实现了Closeable接口,可以用 try-with-resources 管理生命周期,也可以在 Spring 的@PreDestroy钩子里统一关闭。
另外,我一直强调工具刷新不能只做一次。假设你启动时注册了 Server 的 10 个工具,五个小时后 Server 升级并新增了一个工具,Agent 不会感知到。我建议在这个连接器里维护一个带过期时间的工具列表缓存,每次 Agent 开始一轮新的任务之前,检查缓存是否过期,如果过期就重新执行一次tools/list并同步注册中心。这样做既不会让每次请求都产生一次网络开销,也不会让工具能力长期冻结在旧版本。
5. 生产环境避坑:常见问题排查与安全建议
5.1 工具列表拉取为空或一直被拒
最典型的问题出现在初始化没完成或通知没发送。你在日志里如果看到协议层报错,先确认握手流程完整。另外,HTTP 模式下要检查 Server 的 context-path 是否与请求地址一致,有的 Server 注册在/mcp而客户端连的是根路径,就会出现“能连通但怎么都拉不到工具”的情况。
如果你的 MCP Server 以子进程方式运行,也就是使用了 stdio 传输,那么工具拉取为空很可能是子进程启动失败了。因为 stdio 模式下客户端不会把子进程的启动错误当作协议错误返回,你只会看到空结果或连接断开。排查时把子进程的标准错误重定向到日志文件,这样能拿到真实的启动异常。
5.2 JSON Schema 与 Java 类型映射不一致
模型侧解析参数时依赖 schema 里的类型约束。MCP Server 如果定义了一个number类型参数,模型就可能输出小数;如果你内部执行器期望的是整数,转成 Java 类型时就可能出问题。
我的经验是:在注册工具时,不要完全信任 Server 端 schema 定义的完整性。有些第三方 Server 的 schema 书写不规范,缺少必填项或类型描述。AgentScope-Java 侧最好在 executor 里做一次入参校验,必要时给工具加上更严格的 schema 描述。比如把参数类型从number收紧为integer,模型生成的参数就会更符合预期。
还有一个类型映射的细节:MCP schema 里允许嵌套对象和数组,但有些 Agent 框架对复杂嵌套参数的解析支持不完善。如果你发现模型输出的参数没问题但执行器怎么都解析不到正确值,可以先把参数结构简化成一维扁平结构试试。
5.3 工具调用超时与几十秒长任务
MCP 工具不一定都像查天气那样毫秒级返回。数据库查询、文件扫描、AI 推理类的工具可能需要几十秒甚至更久。这会给 Agent 的调用链带来两个问题:一是 HTTP 客户端的连接超时设置不够长,二是模型侧也设置了工具响应超时,一旦超时模型就会以为工具失败了。
解决超时问题前,先想清楚你的工具到底属于哪类。如果是秒级工具,超时设置在 10 到 30 秒比较合适;如果是分钟级长任务,更好的方案是把“提交任务”和“查询结果”拆成两个 MCP 工具。比如submit_job立即返回一个任务 ID,get_job_result让 Agent 轮询结果。这样既符合工具设计的单一职责,也能规避单个 HTTP 请求的超时限制。
5.4 哪些工具不该暴露给 Agent
这是生产环境里最容易忽略的安全问题。MCP Server 可能暴露了几十个工具,但并不意味着所有的都应该开放给你的 Agent 模型调用。Agent 的模型不是一个严格的状态机,它有概率在上下文中生成与预期不符的参数。如果一个工具能修改生产数据、执行 Shell 命令或删除文件,一旦被模型错误调用,代价可能非常大。
建议在工具注册层做一次白名单过滤。你可以在注册时按工具名前缀或元数据进行筛选,比如只注册以query_、get_开头的只读工具,把delete_、update_这类写操作排除在模型决策范围之外。如果你确实需要让 Agent 调用写操作,至少要在 executor 里增加一层人工确认机制,或者在参数校验阶段加入更严格的约束。
对入参校验也不能放松。模型生成的 timezone、路径、SQL 等自由文本参数,最好通过白名单或正则校验后再传给远端。即使 MCP Server 端有自己的校验,Agent 侧的校验仍然值得做,因为你可以在这里记录日志、增加审计点。
5.5 给整个 MCP 接入加观测
MCP 调用链一旦跑起来,你大概率会想知道:当前 Agent 用到了哪个 MCP Server 的哪些工具,调用耗时多少,失败是从协议层还是业务层开始。建议在每个 ToolDefinition 包装 executor 时打日志,记录工具名、请求参数摘要、耗时和错误。
日志里不要打完整参数,尤其是工具涉及密码、Token 等敏感信息。可以在记录参数摘要时把字符串值截断,或只记录参数名列表。另外,给 MCP Server 的请求加一个 traceId 上下文也非常有用,这样从用户问题到工具调用的全链路可以串起来,排查问题时能省一半时间。
6. 把 AgentScope-Java 的 Agent 反向包装成 MCP Server
6.1 什么时候需要反向暴露
上面几章我们都是把 AgentScope-Java 当成 MCP Client 消费外部的工具。但在企业应用里还有另一种需求:你内部已经有一套基于 AgentScope-Java 构建的智能体服务,团队不希望把它的能力丢给外部系统去重复实现,而是想通过 MCP 标准让其他应用调用。
举个例子,A 组维护了一个供应链风险分析 Agent,底层接入了很多内部数据源。B 组的另一个系统是个低代码平台,最近也需要供应链风险分析能力。如果 A 组重新给低代码平台定制 API,会有大量重复工作。换成 MCP 方案,A 组把 Agent 的对外交互封装成一个 MCP Server 工具,B 组只要用 MCP Client 连上来就能调用,完全不管 Agent 内部用了什么模型、什么编排逻辑。
6.2 把 Agent 封装成一个 MCP 工具的代码框架
实现思路是:新建一个 MCP Server,在里面注册一个名为ask_agent的工具,工具入参是用户消息文本,出参是 Agent 返回的答案。当工具被调用时,内部再启动一次 AgentScope-Java 的 Agent 循环。
var server = McpServer.builder(transport) .tool( new McpSchema.Tool( "ask_supply_chain_agent", "Invoke the supply chain risk analysis agent with a user question", "{\"type\":\"object\",\"properties\":{\"question\":{\"type\":\"string\"}},\"required\":[\"question\"]}" ), (exchange, request) -> { String question = request.arguments().get("question").asText(); AgentReply reply = supplyChainAgent.run(question); return Mono.just(McpSchema.CallToolResult.success( new McpSchema.TextContent(reply.getContent()) )); } ) .build();这样设计有一个性能上的隐患:Agent 的内部推理可能很慢,而 MCP 工具的调用方通常会设置一个请求超时。如果一次性把 Agent 的全部思考过程都塞进同一个工具调用里,很容易超时。比较好的做法是参考前面长任务工具的经验:对外暴露submit_question和一个get_answer轮询接口,把 Agent 运行变成异步任务。
6.3 反向暴露时的权限边界
当你把自己的 Agent 暴露成 MCP Server 给外部使用时,你就无法继续控制“谁在调用它”了。因此服务端必须自己实现鉴权,MCP 协议支持在 HTTP 请求头携带认证信息,但不负责业务权限。
还有一个很容易忽视的问题:Agent 内部可能注册了很多内部工具,一旦通过ask_agent这个入口暴露,外部用户可以通过精心设计的问题诱导 Agent 调用内部工具。本质上这是一种间接的工具滥用风险。所以反向暴露时,我强烈建议使用独立的 Agent 配置,只挂载那些允许外部访问的工具,而不是直接复用内部完整的 Agent 实例。
7. 个人经验与这章收尾
最后分享两个我在实践中的体会。第一个是:如果你所在团队刚打算引入 MCP,最忌讳一上来就追求把全部工具都接入。先挑一个只读、低风险、收益明确的工具走通全链路,比如查库存、查天气、读文档。这样做的好处是模型出错时不会造成实际损失,而你可以在低压力下把连接、注册、调用、观测这些链路细节都调顺。等到 Agent 稳定调用第一个 MCP 工具之后,再逐步放量接入其他工具,风险就小得多。
第二个体会是关于版本管理的。MCP Java SDK 还在快速迭代期,类名和包名经常变,这很容易让开发团队产生“等稳定了再用”的想法。我的建议是:把 MCP 相关的 SDK 调用统一封装在一个独立的infra-mcp模块里,上层 Agent 业务只依赖你自己定义的接口。这样即使底层 SDK 发生 breaking change,你也只需要改一个模块,而不是在整个项目里四处打补丁。我和同事在实践中用这种方式平滑升级过好几次 SDK 版本,每次的改动成本都控制在一天以内。
MCP 协议集成看起来只是个技术接入动作,本质上却是在帮你的 Agent 拓宽能力的边界。工具生态标准化这件事,单靠某一个框架是推不动的,但 JVM 生态里的企业级服务如果能通过 MCP 更顺畅地进入 AI 应用,对 Agent 落地是实打实的帮助。你在集成过程中如果遇到这章没覆盖到的问题,建议先去抓协议层的报文,多半答案就藏在报文里。