Java 转 Agent 开发之路(十一):MCP——工具从“本地方法”到“跨服务协议”
当工具开始分布在不同的服务中——检索服务在另一个团队、订单系统在另一个子系统、浏览器自动化在独立的容器里——把每一个都包装成当前应用里的 @Tool 方法就不再合理。你需要的是一套标准化的工具发现和调用协议,让模型看到的工具集不再受限于当前进程。
MCP(Model Context Protocol)解决的就是这件事。但协议本身只规定了“工具如何被描述、如何被调用、结果如何回传”,它不规定权限、不规定租户隔离、不规定审计。这一篇的目标是:把 MCP 的 Server/Client 两侧用可运行代码搭起来,然后诚实地指出协议在哪里结束、工程边界在哪里开始。
先给出这一篇的核心判断:MCP 把工具的执行从“一个 JVM 内的注解”变成了“一个可独立部署、独立鉴权的服务”。工具层的工程约束——幂等、审计、超时、权限——没有消失,它们从方法内部上移到了服务边界和网关层。
一、先分清:本地 @Tool、远程 MCP 工具、以及它们的关系
在 Spring AI 2.0 的 tool loop 中,本地 @Tool、编程式 ToolCallback、远程 MCP 工具,最终都落到同一个抽象:ToolCallback。ToolCallingAdvisor 不关心这个回调是来自当前 JVM 的方法反射,还是来自一个远程 MCP 服务的 JSON-RPC 调用——它只负责驱动“模型请求工具 → 执行 → 结果回灌 → 继续推理”的循环。
这意味着本地工具和远程 MCP 工具可以并存在同一个 ChatClient 上,模型看到的是合并后的工具列表。但它们的工程属性有本质差异:
| 维度 | 本地 @Tool |
远程 MCP 工具 |
|---|---|---|
| 执行位置 | 当前 JVM | 独立进程/服务 |
| 权限上下文 | 方法内部直接获取 | 需要通过请求头/网关传递 |
| 故障边界 | 拖垮当前进程 | 隔离在远程服务 |
| 部署粒度 | 随 Agent 应用发布 | 独立扩缩容、独立发布 |
| 工具发现 | 编译期确定 | 运行时通过 MCP 协议发现 |
一个务实的分工是:核心域逻辑用本地 @Tool,事务、权限、领域校验都在本 JVM 内完成;外围能力(检索、浏览器、内部网关)通过 MCP 暴露,模型侧看到同一套可调用面。不要为了“统一”把核心事务也搬到 MCP 后面——那会引入不必要的网络跳数和故障点。
二、Server 侧:把 @Tool 暴露为 MCP 服务,需要多一步
一个常见的误解是“加了 @Tool 就等于有了 MCP Server”。不是。@Tool 只描述“这个方法可以作为工具能力”,真正把它暴露为 MCP 服务,还需要 MCP Server 依赖和显式的 ToolCallbackProvider 配置。
业务服务类:和本地 @Tool 的写法完全一致
@Service
public class WeatherService {
@Tool(description = "根据城市名称获取天气预报")
public String getWeatherByCity(String city) {
return weatherClient.query(city);
}
}
暴露层:ToolCallbackProvider 是桥梁
@Configuration
public class McpServerConfig {
@Bean
public ToolCallbackProvider weatherTools(WeatherService weatherService) {
return MethodToolCallbackProvider.builder()
.toolObjects(weatherService)
.build();
}
}
这一步的作用是:把 WeatherService 中被 @Tool 标记的方法,通过 ToolCallbackProvider 进入 MCP Server 的能力暴露链路。没有这个 Bean,MCP Server 启动后工具列表是空的。
服务端配置:Spring AI 2.0 已切换到 Streamable-HTTP
SSE 传输在 Spring AI 2.0 中已经被弃用并替换为 Streamable-HTTP。Streamable-HTTP 允许 MCP Server 作为独立进程运行,通过 HTTP POST 和 GET 处理多个客户端连接,可选使用 SSE 流式传输服务器消息。
server:
port: 8014
spring:
ai:
mcp:
server:
protocol: STREAMABLE
name: weather-mcp-server
version: 1.0.0
spring.ai.mcp.server.protocol=STREAMABLE 是启用 Streamable-HTTP Server 的关键配置。依赖侧使用 spring-ai-starter-mcp-server-webflux(响应式)或 spring-ai-starter-mcp-server-webmvc(Servlet)。
三、Client 侧:Starter 只负责发现,不负责接线
这是 MCP 集成中最容易踩的坑,也是这一篇最需要强调的工程细节。
MCP Client 的 Starter 会建立连接、发现远程工具、创建 SyncMcpToolCallbackProvider Bean。但它不会自动把工具挂到 ChatClient 上。启动日志看起来一切正常,联调时模型却“只会聊天、从不调远程工具”——原因不是模型能力,是工具定义根本没有进入模型上下文。
正确做法是显式注入 ToolCallbackProvider,并挂到 ChatClient 上:
@Configuration
public class McpClientConfig {
@Bean
public ChatClient mcpChatClient(ChatModel chatModel,
ToolCallbackProvider mcpTools) {
return ChatClient.builder(chatModel)
.defaultToolCallbacks(mcpTools.getToolCallbacks())
.build();
}
}
defaultToolCallbacks(...) 对每次请求默认可用,单次请求也可以用 .tools(mcpTools) 显式挂载。Client 侧的连接配置对应 Server 的 Streamable-HTTP 端点:
spring:
ai:
mcp:
client:
toolcallback:
enabled: true
streamable-http:
connections:
weather-server:
url: http://localhost:8014
endpoint: /mcp
url 是服务基础地址,endpoint 是 MCP 协议端点,和服务端的 protocol: STREAMABLE 一一对应。
一个必须检查的配置项:spring.ai.mcp.client.toolcallback.enabled=true。如果这个值为 false,ToolCallbackProvider 不会产出回调,显式接线也无从谈起。
本地与远程工具合并
如果同一个 ChatClient 既需要本地 @Tool 又需要远程 MCP 工具,可以在 ChatClient 构建时同时挂载:
ChatClient.builder(chatModel)
.defaultTools(new LocalCustomerTools(orderService))
.defaultToolCallbacks(mcpTools.getToolCallbacks())
.build();
模型看到的是合并后的工具列表,但两套工具的执行路径完全不同:本地工具在 JVM 内执行,MCP 工具走 HTTP/JSON-RPC 到远程服务。ToolCallingAdvisor 对两者一视同仁,但你的超时策略、重试策略、审计埋点需要分开设计。
四、传输协议选择:为什么 Streamable-HTTP 是当前默认答案
MCP 的传输层经历过一次换代。STDIO 适合本地进程(Client 直接 spawn Server 子进程),SSE 是早期的 HTTP 流式方案,Streamable-HTTP 在 Spring AI 2.0 中取代了 SSE。
Streamable-HTTP 的核心优势是无状态与有状态之间的灵活切换:Server 可以作为独立进程处理多个 Client 连接,不要求长连接始终存在;需要推送时可以选配 SSE 流式。对于企业部署,这意味着 MCP Server 可以像普通 Web 服务一样被负载均衡、被网关代理、被独立扩缩容。
选择建议很直接:本地工具进程用 STDIO,跨服务/跨网络的 MCP 用 Streamable-HTTP。不要再在新项目中基于 SSE 做设计,Spring AI 2.0 的配置和 Starter 已经围绕 Streamable-HTTP 重构。
五、企业级部署:MCP Gateway 与 Nacos 的位置
当 MCP Server 数量增长到十几个、分布在多个团队时,“Client 直接连每一个 Server”会变成运维负担。Spring AI Alibaba 的 MCP Gateway 提供了中间代理层:MCP Server 注册到 Nacos,Gateway 将 Nacos 中的服务信息动态转换为 MCP 工具,Client 只连 Gateway。
Gateway 的价值不在“协议转换”,在集中化的控制面:
- 动态发现:新增或删除 MCP Server 无需重启 Gateway,Nacos 中的变更会被
NacosMcpGatewayToolsWatcher监听并自动更新工具注册表。 - 协议适配:Gateway 可以把对 MCP 工具的调用转换为对后端 HTTP、Dubbo 等服务的请求,存量 RESTful 服务无需改造即可被模型调用。
- Client 侧简化:Client 只需连接 Gateway,不需要为每个 MCP Server 维护一套连接配置。
LoadbalancedMcpSyncClient可以通过 Nacos 发现 Gateway 实例并做负载均衡。
一个关键的部署细节:Gateway 模块默认不会注册为 Nacos 的 MCP 服务和实例。需要引入 spring-ai-alibaba-starter-mcp-registry 依赖,并在配置中开启 register.enabled=true,Gateway 才会把自己也注册到 Nacos,让 Client 能发现它。这是企业级部署中容易遗漏的一步。
六、协议结束的地方:权限、租户隔离、审计必须在网关层
这是这一篇最重要的工程判断。
MCP 协议规定了工具如何被描述、如何被发现、如何被调用。它不规定:谁能发现哪些工具、谁能调用哪些工具、调用记录存在哪里、跨租户的调用如何隔离。
一个裸 MCP Server 会把所有工具暴露给所有调用者,没有身份、没有配额、没有记录、没有脱敏。这在本地开发中没问题,在多租户生产环境中不可接受。
权限和审计不应该由 Agent 的 Advisor 链来补,也不应该只靠工具方法内部的 if-else。 正确的分层是:
工具发现阶段:按租户过滤可见工具
MCP 的 tools/list 响应应该根据调用者的租户和角色过滤。一个租户不应该“发现”另一个租户的工具——即使调用时会被拒绝,工具名称和描述本身也是信息泄露。这一层需要在 Gateway 或 MCP Server 的暴露层完成,而不是在 Client 侧“假装看不到”。
工具调用阶段:RBAC + 参数级归属校验
工具级别的 RBAC 判断“这个角色能不能调这个工具”,参数级别的归属校验判断“这个订单是否属于当前租户”。前者可以在 Gateway 层用策略文件声明,后者必须在工具方法内部用请求上下文中的租户身份校验模型生成的参数。模型给出的订单号可能是合法的,但不属于当前租户——这个判断不能交给模型,也不能交给网关(网关不知道业务归属)。
审计:在调用边界记录,而不是在模型输出中事后猜测
MCP 的工具调用天然经过一个明确的边界(HTTP/JSON-RPC 端点)。在这个边界上记录 who/what/when/result/latency,比在 Agent 的日志里从模型输出中反推“它调了什么”可靠得多。一个可参考的实现是 append-only 的 JSONL 审计日志,记录调用者、工具名、脱敏后的参数、结果状态和耗时,可导出到 SIEM。
这三个平面都不能只靠 Spring AI 的 Advisor 来解决。Advisor 能看到工具回调的入参和出参,但它不知道租户身份从哪来(除非你显式传递),不知道网关层的策略,也不应该承担审计存储的职责。MCP 的工程边界,是Gateway 管发现和粗粒度 RBAC,工具方法管参数级归属,独立的审计组件管记录——三者各司其职,而不是塞进一条 Advisor 链。
七、收束:MCP 是协议,不是治理方案
这一篇从 Server 侧的 ToolCallbackProvider 讲到 Client 侧的显式接线,再到 Streamable-HTTP 的协议换代和 Gateway/Nacos 的部署模式,核心可以收束为三个判断。
第一,MCP 让工具从“方法”变成“服务”,但接线仍然是开发者的责任。 Starter 负责连接和发现,defaultToolCallbacks 负责把发现到的回调交给 ChatClient。两者缺一不可,而后者是新手最容易遗漏的一步。
第二,协议本身不提供治理。 工具发现时的租户过滤、调用时的参数级归属校验、边界上的审计记录,是 MCP 之上必须自己补的三层。把它们塞进 Advisor 链,只会让治理逻辑和调用逻辑纠缠在一起,失去独立演进的能力。
第三,Streamable-HTTP 是企业部署的默认传输,Gateway + Nacos 是多服务场景的合理控制面。 但 Gateway 解决的是“工具如何被集中发现和代理”,不是“权限如何被强制”——权限的强制点仍然在 Gateway 的策略层和工具方法的归属校验里,两者不能互相替代。
- 点赞
- 收藏
- 关注作者
评论(0)