让模型会调工具不难,难的是调完之后那几步。一个客服 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 网关都行,这比在工具方法里补权限更根本。