让模型会调工具不难,难的是调完之后那几步。一个客服 Agent 接上退款系统,用户说"帮我把这笔退了",模型可能把退款单号传错、替一个没权限的账号发起退款、或者把"先出个方案"理解成"直接执行"。这三类问题,随便哪一个发生,损失都是真金白银。
MCP 解决的是"工具怎么被发现、怎么调用",它不替你解决"谁有权限调、调了要不要人确认"。Spring AI 的 MCP HTTP 服务端默认也不加鉴权——任何能摸到 /mcp 端点的人,都能列出并调用上面的所有工具。所以可靠性这件事,得在应用层自己补。
下面用一条退款审批的最小链路,把三道边界拆开:参数校验、权限确认、人工审批。代码基于 Spring AI 2.0 的注解式 MCP 工具(@McpTool / @McpToolParam),模型用一个 OpenAI 兼容的 ChatModel 占位,换成别的供应商不影响这套边界。
链路与两个应用
为了把 MCP 的边界看清楚,这里拆成两个 Spring Boot 应用:refund-mcp-server 暴露退款单查询和退款方案生成两个工具;agent-api 接收对话、连模型、接 MCP 客户端,并对外提供确认接口。
两个应用都依赖 Spring AI 的 BOM,MCP 服务端用 spring-ai-starter-mcp-server-webmvc:
org.springframework.ai
spring-ai-bom
2.0.0
pom
import
org.springframework.boot
spring-boot-starter-webmvc
org.springframework.ai
spring-ai-starter-mcp-server-webmvc
配置里指定 Streamable HTTP 传输,服务端会监听 POST /mcp:
spring:
ai:
mcp:
server:
name: refund-mcp-server
version: 1.0.0
protocol: STREAMABLE
先立一条底线:查询是只读工具,退款这个破坏性操作只通过工具生成"待确认方案",工具本身不执行退款。确认接口从 Spring Security 的已认证用户上下文里取操作者身份,不信任模型输出或请求体里传上来的 userId。
参数校验
工具入参来自模型输出,第一道校验就是让非法参数在进业务逻辑之前被打回。Spring AI 会用注解生成工具定义和参数 Schema,@McpToolParam 里写的描述既给模型看,也给维护者看:
@Component
public class RefundTools {
private final RefundService refundService;
public RefundTools(RefundService refundService) {
this.refundService = refundService;
}
@McpTool(name = "get_refund_status", description = "只读查询退款单状态,不修改任何数据")
public RefundStatus getRefundStatus(
@McpToolParam(description = "退款单号,例如 RF-20261001-0001", required = true)
String refundNo) {
validateRefundNo(refundNo);
return refundService.statusOf(refundNo);
}
@McpTool(name = "prepare_refund", description = "生成退款待确认方案,只保存方案,不实际发起退款")
public RefundPlan prepareRefund(
@McpToolParam(description = "退款单号", required = true) String refundNo,
@McpToolParam(description = "退款原因", required = true) String reason) {
validateRefundNo(refundNo);
if (reason == null || reason.isBlank() || reason.length() > 200) {
throw new IllegalArgumentException("退款原因必须在 1 到 200 个字符之间");
}
return refundService.createPendingRefund(refundNo, reason.trim());
}
private void validateRefundNo(String refundNo) {
if (refundNo == null || !refundNo.matches("RF-\\d{8}-\\d{4}")) {
throw new IllegalArgumentException("退款单号格式不合法");
}
}
}
两个方法开头都先做格式校验,prepare_refund 还额外限制了 reason 的长度。模型偶尔会把 refundNo 和 orderNo 搞混,或者把整段对话塞进 reason,这层校验拦下的正是这类噪声。
一个容易踩的点:@McpTool 方法如果抛的是受检异常,会以 UndeclaredThrowableException 的形式冒出来,而不是转成给模型看的错误结果。所以想让它回到模型那里的错误,得用运行时异常包一层,就像上面直接抛 IllegalArgumentException。
权限确认
MCP 服务端默认没有鉴权,但这不代表权限校验可以省。退款这类操作,必须确认"当前操作者到底有没有权限碰这张单"。权限判断放在确认接口里,而不是工具里——工具只负责生成方案,真正动钱的是确认这一步:
@PostMapping("/refunds/{actionId}/confirm")
public ConfirmationResult confirm(
@PathVariable UUID actionId,
Authentication authentication) {
String operator = authentication.getName();
RefundPlan plan = refundService.findPending(actionId);
if (plan == null) {
throw new IllegalStateException("方案不存在或已处理");
}
// 权限校验:操作者必须属于退款单归属的租户
if (!plan.tenantId().equals(operatorTenantOf(operator))) {
throw new AccessDeniedException("无权处理该退款单");
}
// 状态校验:只处理 pending 状态,防止重复执行
if (!"pending".equals(plan.status())) {
throw new IllegalStateException("方案状态为 " + plan.status() + ",不能重复确认");
}
refundService.execute(plan, operator);
return new ConfirmationResult(actionId, "EXECUTED");
}
身份从 Authentication 取,不是从 JSON 里取。这是整条链里最关键的一处:模型可以输出任意文本,请求体里的 userId 是谁都能伪造,但 Spring Security 上下文里的身份是登录时建立、加密校验过的。
人工审批
只读查询可以直接回给模型,破坏性操作则必须留一道人工闸。上面的 prepare_refund 只把方案写进待确认队列,模型拿到的是一个 actionId 和一句"需要人工确认",而不是退款已完成的假象。
模拟存储层用内存 Map 就够了,正式环境要换数据库,并补上过期时间、租户字段和审计信息:
@Service
public class RefundService {
private final Map pending = new ConcurrentHashMap();
public RefundStatus statusOf(String refundNo) {
// 生产环境查真实退款系统,这里用固定值演示
return new RefundStatus(refundNo, "WAITING", "2026-10-22T10:00:00Z");
}
public RefundPlan createPendingRefund(String refundNo, String reason) {
UUID actionId = UUID.randomUUID();
RefundPlan plan = new RefundPlan(
actionId, refundNo, reason, "tenant-a", "pending", Instant.now());
pending.put(actionId, plan);
return plan;
}
public RefundPlan findPending(UUID actionId) {
return pending.get(actionId);
}
public void execute(RefundPlan plan, String operator) {
// 这里才是真正调用退款接口的地方,用幂等键 actionId 防重
plan = plan.markExecuted(operator);
pending.put(plan.actionId(), plan);
}
}
RefundStatus、RefundPlan 用普通 record 定义,字段名、结构都是自己定的:
public record RefundStatus(String refundNo, String state, String updatedAt) {}
public record RefundPlan(
UUID actionId,
String refundNo,
String reason,
String tenantId,
String status,
Instant createdAt) {
public RefundPlan markExecuted(String operator) {
return new RefundPlan(actionId, refundNo, reason, tenantId, "executed", createdAt);
}
}
客户端这边,agent-api 用 SyncMcpToolCallbackProvider 拿到 MCP 工具,再挂到 ChatClient 上。Spring AI 2.0 里 MCP 工具不会自动注册到 ChatClient,需要显式装配,因为启动时就去连每个 MCP server 会拖慢启动:
@Bean
ChatClient chatClient(ChatModel chatModel, SyncMcpToolCallbackProvider mcpTools) {
return ChatClient.builder(chatModel)
.defaultTools(mcpTools)
.build();
}
工具执行循环在 Spring AI 2.0 里被提升到 Advisor 链,本地 @Tool 方法和远程 MCP 工具走的是同一个 ToolCallback 接口,模型和编排层分不清它们,所以可以混着挂。
值不值得做
这套"校验 + 权限 + 人工确认"的结构不是银弹,它解决的是"模型替系统做决定,但决定有代价"的那类场景:退款、权限变更、发布上线、删除数据。这些操作一旦误执行不可逆,人工确认的成本远低于误操作的成本。
反过来,如果是查天气、查余额、读文档这类只读操作,硬套人工审批就是纯负担。判断标准很简单:这个操作能不能回滚、失败成本有多高。不能回滚又贵的,走确认闸;能回滚或者纯查询的,放模型直接做,把精力省下来去写更严的参数校验。
还有一条边界要认清:MCP 服务端一旦暴露到公网,/mcp 就是无鉴权的 JSON-RPC 端点。生产环境必须在它前面加一层安全边界,Spring Security 的过滤器或者 API 网关都行,这比在工具方法里补权限更根本。