手搓生产级 AI Agent 系统(38):Spring AI + MCP 工具调用可靠性三道防线
让大模型“会调用工具”只是起点。进入业务系统后,真正棘手的是:模型传错设备编号、当前用户没有权限、模型把“重启设备”理解成“立刻执行”、失败重试导致同一条指令执行两遍。本文作为系列第 38 篇,继续围绕生产级 Agent 的可靠性工程,用 Spring AI + MCP 搭一条最小但完整的工具调用链,把“建议”和“执行”彻底分开。
事实边界说明: 本文的版本矩阵、Starter 名称、@McpTool 注解 API 与 application.yml 配置项,均来自社区教程示例,尚未经 Spring AI 官方文档或源码逐项核验。读者落地前应以所用版本的官方文档、BOM 与源码为准。下文对“MCP HTTP 服务端默认鉴权行为”的描述同样属于待核验判断,不应理解为官方已确认结论。
先明确一条工程底线:MCP 解决工具如何被发现和调用,不会自动替业务做身份认证、权限判断或人工审批。 无论 MCP 服务端默认是否提供鉴权,上线前都必须把认证、授权与审批边界设计到应用或网关里。
一、整体结构:建议与执行分离
社区示例的基线为 Java 17+、Spring Boot 4.0.x、Spring AI 2.0.1;该组合的兼容性需以官方发布说明为准。模型 API 与 MCP Server 分开配置;敏感操作只生成待确认方案,不让模型直接执行。
用户
│ POST /api/chat
▼
Agent API(Spring Boot + Spring AI)
│ 模型选择工具,并给出参数
▼
MCP Client ───── Streamable HTTP ─────► Device MCP Server
│ 只读查询 / 生成操作方案
│ 用户看到待确认卡片 ◄─────────────────────────┘
│ 已登录的用户点击确认
▼
审批 API:权限检查 + 状态检查 + 幂等键
│
▼
设备控制服务
两个 Spring Boot 应用:device-mcp-server 暴露设备状态查询与重启方案预览;agent-api 接收对话、调用模型、连接 MCP Server、提供确认接口。读设备状态是只读工具;“重启设备”只通过工具生成操作方案,工具本身不执行重启。确认接口从 Spring Security 的已认证用户上下文取得操作者身份,不能从模型输出或请求 JSON 里的 userId 取身份。
二、版本与依赖(社区示例,需官方核验)
社区示例称 Spring AI 2.0.x 对应 Spring Boot 4.0.x / 4.1.x,并建议两个应用导入同一 BOM 以避免模块版本不一致。以下依赖坐标与版本号均来自该示例,落地前请对照官方 BOM 与 Starter 清单确认:
org.springframework.ai
spring-ai-bom
2.0.1
pom
import
示例中两个模块的 pom.xml 都使用 Spring Boot 4.0.x parent,并设置 Java 17。device-mcp-server 依赖 spring-boot-starter-webmvc 与 spring-ai-starter-mcp-server-webmvc;agent-api 依赖 spring-boot-starter-webmvc、spring-boot-starter-security、spring-ai-starter-model-openai 与 spring-ai-starter-mcp-client。这些 Starter 名称需以官方文档为准。API Key 放环境变量或密钥系统,不要提交到 Git。
三、MCP Server:只暴露必要能力
社区示例用 @McpTool 声明工具,并据此生成 MCP 工具定义和参数 Schema。该注解的包路径、属性名与行为需以官方文档核验。描述要写清楚用途、输入和副作用边界,因为它既给模型看,也给维护者看。
@Component
public class DeviceTools {
private final DeviceService deviceService;
@McpTool(
name = "get_device_status",
description = "只读查询设备状态。参数为设备编号,不会修改设备。",
generateOutputSchema = true,
annotations = @McpTool.McpAnnotations(readOnlyHint = true, destructiveHint = false)
)
public DeviceStatus getDeviceStatus(
@McpToolParam(description = "设备编号,例如 pump-017", required = true) String deviceId) {
validateDeviceId(deviceId);
return deviceService.getStatus(deviceId);
}
@McpTool(
name = "prepare_device_restart",
description = "创建一个待人工确认的设备重启方案;会保存待审批记录,但不会执行重启。",
generateOutputSchema = true,
annotations = @McpTool.McpAnnotations(readOnlyHint = false, destructiveHint = false, idempotentHint = false)
)
public RestartPlan prepareRestart(
@McpToolParam(description = "设备编号,例如 pump-017", required = true) String deviceId,
@McpToolParam(description = "用户说明的重启原因", required = true) String reason) {
validateDeviceId(deviceId);
if (reason == null || reason.isBlank() || reason.length() > 200) {
throw new IllegalArgumentException("重启原因必须为 1 到 200 个字符");
}
return deviceService.createPendingRestart(deviceId, reason.trim());
}
private void validateDeviceId(String deviceId) {
if (deviceId == null || !deviceId.matches("[a-zA-Z0-9_-]{3,40}")) {
throw new IllegalArgumentException("设备编号格式不合法");
}
}
}
prepareRestart 会创建待审批记录,所以它不是只读工具;但它不触碰设备,不能把“生成方案”伪装成“已经执行”。readOnlyHint、destructiveHint 都是发给 MCP 客户端的提示,不是访问控制机制;真正的安全边界仍然要靠服务端代码、认证和授权策略。
社区示例的 device-mcp-server/src/main/resources/application.yml 如下,配置项名称需以官方文档核验:
server:
port: 8081
spring:
ai:
mcp:
server:
protocol: STREAMABLE
type: SYNC
annotation-scanner:
enabled: true
本地演示可以只绑定 localhost。无论 MCP HTTP 端点默认是否提供认证授权,部署到网络环境时都应在 Spring Security、API Gateway 或其他可信边界上配置认证,并明确哪些服务身份可以读取、哪些用户可以审批。
四、Agent API 连接 MCP Server
社区示例的 agent-api/src/main/resources/application.yml 如下,连接配置项需以官方文档核验:
server:
port: 8080
spring:
ai:
openai:
api-key: ${OPENAI_API_KEY}
chat:
model: ${CHAT_MODEL:gpt-5-mini}
mcp:
client:
type: SYNC
request-timeout: 20s
streamable-http:
connections:
device:
url: http://localhost:8081
endpoint: /mcp
示例称 MCP Client Starter 会发现 MCP Server 暴露的工具,并把它们适配成 Spring AI 的 ToolCallback。Server 与 Client 的传输协议都设为 Streamable HTTP;远程部署时,地址应改成可达的服务地址,并启用身份认证。
构造 ChatClient,把 MCP 工具提供者注册到本次 Agent 会话:
@Configuration
public class AiConfiguration {
@Bean
ChatClient deviceChatClient(ChatClient.Builder builder, ToolCallbackProvider mcpTools) {
return builder
.defaultSystem("""
你是设备运维助手。
查询设备时,只使用已提供的只读工具。
用户提出重启请求时,只生成重启方案并说明需要人工确认;
不得声称设备已经重启,也不得把工具参数当作用户权限证明。
""")
.defaultTools(mcpTools)
.build();
}
}
聊天接口只负责把用户问题交给 Agent。用户问“pump-017 最近温度怎么样?”,模型可以选择 get_device_status,Spring AI 通过 MCP 调用 Server,再把工具结果交回模型组织答案。用户要求重启时,模型可以选择 prepare_device_restart,Server 创建一个有过期时间的待审批记录,并返回 actionId;设备本身仍不会重启。工具调用过程是:模型提出工具名和参数 → 应用执行工具 → 工具结果返回模型 → 模型生成最终答复。模型只是提出调用,执行与否始终由应用掌控。
五、第一道防线:参数校验在服务端再做一遍
MCP Schema 能帮助模型生成格式正确的参数,但不能代替服务端校验。请求可能来自非模型客户端,也可能是模型错误生成或遭遇提示注入后的工具调用。至少做这些检查:字段是否存在、长度和格式是否符合预期;设备是否真实存在、是否属于当前租户;时间范围、金额、数量等边界是否合理;查询是否有最大返回量和超时;文本输入是否可能被用于 SQL、Shell 或 URL 拼接。SQL 查询使用参数化语句;调用外部设备接口时使用明确的设备 ID 映射和超时。不要仅靠 Prompt 写“不能越权”,也不要认为模型已经拿到正确 JSON 就可以跳过业务校验。
六、第二道防线:权限校验身份来自登录上下文
用户发来的文本、MCP 工具参数、模型生成的 JSON 都是不可信输入。权限判断应当发生在服务端,使用认证后的 principal 和资源归属关系:
@PreAuthorize("hasAuthority('device:restart:approve')")
@PostMapping("/api/actions/{actionId}/confirm")
public ActionReceipt confirm(@PathVariable UUID actionId, Authentication authentication) {
return approvalService.confirmOnce(actionId, authentication.getName());
}
这段接口要配合 Spring Security 的认证配置和方法级安全(@EnableMethodSecurity)。审批服务还要确认:该 action 属于当前租户、状态仍是 PENDING、没有过期、目标设备允许被该操作者管理。任何一项不满足都拒绝执行。特别注意:如果 MCP Server 是独立服务,MCP 请求使用的服务凭据不等于最终用户身份。要么由可信 API 层完成用户级授权,要么使用有明确受众、范围和有效期的用户委托凭证;不要把 userId 作为普通工具参数让模型传过去。
七、第三道防线:人工确认与幂等执行
高风险操作只生成待确认方案,最终由经过认证的人工操作确认后执行。确认接口需要同时完成权限检查、状态检查和幂等键校验。approvalService.confirmOnce 应在同一事务内完成状态流转,避免重复确认导致重复执行;待审批记录要保存到数据库,并设置过期时间、租户字段和审计信息,而不是像演示代码那样放在内存 Map 里。
工程上建议把三道防线串成固定顺序:工具服务端先做参数与资源归属校验;审批接口再做用户权限与状态校验;执行层用幂等键保证同一 actionId 只生效一次。任何一道防线失败都返回明确错误,不把异常吞掉交给模型“自行解释”。
八、边界与取舍
MCP 的注解提示、工具描述和系统提示词都只是“给模型看的建议”,不能替代服务端控制。把只读查询与写操作拆成不同工具,把“生成方案”与“执行方案”拆成不同接口,是这套结构能成立的前提。代价是链路变长、需要额外存储待审批记录,但换来的是可审计、可拒绝、可重试而不重复执行。对于设备控制这类副作用明确的场景,这个取舍是值得的。
最后再次提醒:本文代码与配置来自社区示例,Spring AI 的版本兼容性、Starter 名称、注解 API 与默认鉴权行为均需以官方文档或源码为准;在生产环境落地前,应补充官方来源核验与压测验证。