Spring AI + MCP 工具调用可靠性实战:参数校验、权限防线与人工确认生产级落地

本文同步发布于 CSDN,聚焦 AI Agent 工具调用从"演示能跑"到"生产敢上"的可靠性工程。

一、为什么工具调用是 Agent 从 Demo 走向生产最脆弱的一环

最近 CSDN 热榜上有一篇文章引发广泛讨论:《AI Agent 工具调用如何更可靠?Spring AI + MCP 实战:参数校验、权限校验与人工确认》。它戳中了一个所有把 Agent 推向生产环境的团队都绕不开的问题:

模型负责理解意图、提出下一步动作,但系统必须掌握"谁能做、做什么、何时真正执行"的决定权。

一个真实场景:运维团队给 Agent 接了一个"重启服务"的 MCP 工具,模型在一次多轮对话中误解了用户意图,把 restart api-gateway 理解成 restart database,工具参数直接透传给 MCP Server 执行------生产数据库重启,P0 事故。这不是段子,是过去一年里无数团队真实踩过的坑。

问题出在哪?不在模型不够聪明,而在于调用边界没有设计清楚 。Spring AI 的工具调用链路本身只负责"执行",它默认信任模型产出的一切:参数不校验、身份不验证、执行不确认。MCP 协议把工具能力标准化了,但MCP 协议本身不包含权限控制机制------它把安全责任交给了 Host(Spring AI 官方文档明确说明了这一设计取舍)。

这篇文章我们围绕一个完整的企业运维 Agent 项目 OpsPilot ,把工具调用可靠性拆成三道防线逐一落地:参数校验、权限校验、人工确认(HITL)。全部基于 Spring AI 2.0 + MCP Client/Server 真实 API,不是 Hello World。

二、先看清楚 Spring AI 工具调用的执行链路

要加固边界,先得看清边界在哪。Spring AI 的工具调用核心链路是这样的:

text 复制代码
ChatModel.chat(prompt)
  └─ DefaultToolCallingManager.executeToolCalls(prompt, chatResponse)
       ├─ 1. ToolExecutionEligibilityChecker 判断是否需要执行工具
       │     (isInternalToolExecutionEnabled && chatResponse.hasToolCalls())
       ├─ 2. 遍历 AssistantMessage.getToolCalls()
       ├─ 3. findToolCallback(toolName) 解析出对应 ToolCallback
       │     (MethodToolCallback 反射 / FunctionToolCallback / SyncMcpToolCallback)
       ├─ 4. 解析 JSON 参数 → callback.call(arguments, toolContext) → 执行
       └─ 5. buildConversationHistoryAfterToolExecution()
             把 AssistantMessage(工具调用请求) 和 ToolResponseMessage(结果)
             追加进历史,交回模型继续推理

几个关键事实:

  • DefaultToolCallingManager.executeToolCall() 从模型产出的 JSON 字符串解析参数后,直接反射调用 Java 方法或转发给 MCP Server 。中间没有任何校验、没有任何权限判断。也就是说,模型 hallucinate 出 {"path": "../../etc/passwd"},这个参数会原样到达你的工具方法。
  • MCP 工具通过 SyncMcpToolCallbackProvider 桥接 :Spring AI 的 MCP Client Boot Starter 启动时连接 MCP Server,把 tools/list 拿到的工具元数据(name/description/inputSchema)包装成 ToolCallback 列表,注入到 ToolCallingChatOptions 中。tools/call 请求的参数是模型生成的,MCP Server 端拿到的就是这份未经验证的 JSON。
  • ToolContext 是唯一可信的旁路数据通道 :它由调用方代码构造,模型接触不到。这意味着"真实用户身份"应该放进 ToolContext,而不是让模型在参数里自报"我是管理员"。

边界看清了,三道防线的位置也就清楚了:参数校验卡在解析后执行前,权限校验卡在身份可信通道上,人工确认卡在执行前的最后一道闸门。

三、实战:OpsPilot 运维 Agent 的三道防线

3.1 项目骨架

技术栈:Spring Boot 4 + Spring AI 2.0 + MCP Client Boot Starter(对接内部 MCP Server)+ Spring Security。核心依赖:

xml 复制代码
<dependency>
    <groupId>org.springframework.ai</groupId>
    <artifactId>spring-ai-starter-mcp-client-webmvc</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
</dependency>

配置文件:

yaml 复制代码
spring:
  ai:
    mcp:
      client:
        enabled: true
        type: SYNC
        toolcallback:
          enabled: true   # 自动把 MCP 工具注册为 ToolCallback
        sse:
          connections:
            ops-server:
              url: https://mcp-ops.internal:8443

启动后,MCP Server 上的 restartService、queryServiceStatus、scaleDeployment、execDiagnostics 等工具自动出现在 Spring 容器的 ToolCallbackProvider 里。

3.2 防线一:参数校验------Schema 是给模型看的,校验是给系统做的

MCP 的 inputSchema 帮助模型理解参数怎么填,但Schema 约束不等于业务校验。模型可以填出格式完全合法但语义危险的参数:路径穿越、超范围的 replica 数、不在白名单的服务名。

第一层:服务名白名单 + 规模上限,在工具入口处强制收敛参数空间:

java 复制代码
@Service
public class OpsToolGuard {

    // 只允许操作注册在案的巡检目标,收敛模型可触达的爆炸半径
    private static final Set<String> ALLOWED_SERVICES = Set.of(
            "api-gateway", "order-service", "inventory-service");

    private static final int MAX_REPLICAS = 20;

    /** 返回空 Optional 表示校验通过;否则返回给模型的拒绝原因 */
    public Optional<String> validateRestart(RestartRequest req) {
        if (!ALLOWED_SERVICES.contains(req.serviceName())) {
            return Optional.of(
                "服务 %s 不在运维白名单内,允许的操作目标: %s"
                    .formatted(req.serviceName(), ALLOWED_SERVICES));
        }
        if (req.confirmToken() == null || req.confirmToken().isBlank()) {
            return Optional.of("缺少 confirmToken,请先通过预览接口获取确认令牌");
        }
        return Optional.empty();
    }

    public Optional<String> validateScale(String service, int replicas) {
        if (!ALLOWED_SERVICES.contains(service)) {
            return Optional.of("服务 %s 不在运维白名单内".formatted(service));
        }
        if (replicas < 0 || replicas > MAX_REPLICAS) {
            return Optional.of("replicas 超出允许范围 [0, %d],当前值: %d"
                    .formatted(MAX_REPLICAS, replicas));
        }
        return Optional.empty();
    }
}

第二层:路径与文件类参数做规范化穿越检查 。文件类工具是参数注入的重灾区,模型可能生成 ../../etc/passwd 这类路径:

java 复制代码
public final class PathGuard {

    private final Path baseDir;

    public PathGuard(Path baseDir) throws IOException {
        this.baseDir = baseDir.toRealPath();
    }

    /** 把模型给的任意路径约束在 baseDir 内,越界即拒绝 */
    public Path resolve(String userPath) throws IOException {
        Path resolved = baseDir.resolve(userPath).normalize().toRealPath();
        if (!resolved.startsWith(baseDir)) {
            throw new SecurityException(
                "路径越界: " + userPath + " 解析后不在允许目录内");
        }
        return resolved;
    }
}

两个工程细节值得强调:

  1. 拒绝要以"工具结果"的形式返回给模型,而不是抛异常终止 。Spring AI 的 DefaultToolCallingManager 对工具异常的处理是中断整轮循环,模型失去"修正后重试"的机会。校验失败时返回一句描述性文本,模型会理解并换一种问法或提示用户,交互体验完全不同。
  2. 工具方法返回结构化对象而不是拼好的 String。Spring AI 会自动把 Java 对象序列化成结构化内容,模型能基于字段推理,编造内容的概率大幅降低。

3.3 防线二:权限校验------身份来自认证上下文,绝不接受模型自报

这是最容易出事、也最容易被忽视的边界。反面做法长这样:

java 复制代码
// ❌ 绝对禁止:把"操作人"作为工具参数交给模型填
@Tool(description = "以指定用户身份重启服务")
public String restartService(String operatorId, String serviceName) { ... }

模型填什么都行,这等于没有权限系统。正确做法是:用户身份从认证上下文取(SecurityContextHolder),通过 ToolContext 旁路传入工具方法:

java 复制代码
@RestController
public class ChatController {

    private final ChatClient chatClient;

    public ChatController(ChatClient.Builder builder,
                          ToolCallbackProvider mcpTools) {
        this.chatClient = builder
                .defaultToolCallbacks(mcpTools)
                .build();
    }

    @PostMapping("/api/chat")
    public Flux<String> chat(@RequestBody ChatRequest req,
                             Authentication auth) {
        // 身份从 Spring Security 上下文获取,模型无法伪造
        ToolContext ctx = new ToolContext(Map.of(
                "userId", auth.getName(),
                "roles", auth.getAuthorities().stream()
                        .map(GrantedAuthority::getAuthority).toList()));

        return chatClient.prompt()
                .user(req.message())
                .toolContext(ctx)   // ToolContext 对模型不可见,是可信通道
                .stream()
                .content();
    }
}

工具方法侧,从 ToolContext 取身份做 RBAC 判断:

java 复制代码
@Tool(name = "scaleDeployment",
      description = "扩缩容指定服务的 Deployment 副本数")
public String scaleDeployment(
        @ToolParam(description = "服务名") String service,
        @ToolParam(description = "目标副本数") int replicas,
        ToolContext ctx) {

    String userId = (String) ctx.getContext().get("userId");
    @SuppressWarnings("unchecked")
    List<String> roles = (List<String>) ctx.getContext().get("roles");

    // 写操作要求 SRE 角色;工具描述和 Prompt 都不能替代代码里的权限判断
    if (!roles.contains("ROLE_SRE")) {
        return "拒绝: 用户 %s 无 SRE 角色,不允许执行扩缩容操作".formatted(userId);
    }
    Optional<String> invalid = guard.validateScale(service, replicas);
    if (invalid.isPresent()) {
        return invalid.get();   // 校验拒绝也以工具结果返回,让模型可自我修正
    }

    return opsService.scale(service, replicas, userId);
}

如果你自己就是 MCP Server 的提供方(spring-ai-starter-mcp-server-webmvc),还可以直接用方法级安全注解------Spring AI 的 MCP Server 安全模块支持在 @McpTool 方法上使用 @PreAuthorize,并在工具方法内通过 SecurityContextHolder 直接访问当前认证信息:

java 复制代码
@McpTool(name = "restartService",
         description = "重启指定服务(高危操作)")
@PreAuthorize("hasRole('SRE')")
public String restartService(
        @ToolParam(description = "服务名") String service) {
    var auth = SecurityContextHolder.getContext().getAuthentication();
    return opsService.restart(service, auth.getName());
}

再补一层场景化工具白名单 :不要盲目信任 MCP Server 暴露的所有工具。按会话场景动态构造 ToolCallbackProvider,普通客服会话根本不该看到 restartService:

java 复制代码
public ToolCallbackProvider toolsForScenario(String scenario) {
    // 按标签过滤客户端注册的 MCP 工具,读写分离,危险工具默认不下发
    List<McpSyncClient> clients = clientRegistry.getByTag(scenario);
    return SyncMcpToolCallbackProvider.builder()
            .mcpClients(clients)
            .build();
}

只读查询工具自动执行,读写分离,危险操作默认不下发或走审批------工具下发的最小化原则,本身就是最便宜的权限防线。

3.4 防线三:HITL 人工确认------预览、审批、幂等执行

Spring AI 官方仓库的 Tool Approval Strategy 设计提案(discussion #4878) 提出了 ToolMetadata.requiresApproval() + ToolApprovalStrategy 的方案:DefaultToolCallingManager 在执行前咨询审批策略,拒绝则把结构化拒绝原因回传给模型而不执行工具。这个方向还在演进,生产上现在更常见的做法是装饰 ToolCallingManager,在工具循环里插一道审批闸门:

java 复制代码
public class HitlToolCallingManager implements ToolCallingManager {

    private final ToolCallingManager delegate;   // DefaultToolCallingManager
    private final ApprovalRegistry approvals;
    private final Set<String> gatedTools;        // 需人审的工具清单

    public HitlToolCallingManager(ToolCallingManager delegate,
                                  ApprovalRegistry approvals,
                                  Set<String> gatedTools) {
        this.delegate = delegate;
        this.approvals = approvals;
        this.gatedTools = gatedTools;
    }

    @Override
    public ToolExecutionResult executeToolCalls(Prompt prompt,
                                                ChatResponse chatResponse) {
        List<AssistantMessage.ToolCall> calls =
                chatResponse.getResult().getOutput().getToolCalls();

        Map<String, String> results = new LinkedHashMap<>();
        for (AssistantMessage.ToolCall call : calls) {
            if (gatedTools.contains(call.name())) {
                // 高危工具:注册审批单,阻塞等待人工决策
                String requestId = approvals.register(call);
                ApprovalDecision decision =
                        approvals.await(requestId, Duration.ofMinutes(2));
                // fail-safe:超时/拒绝一律不放行
                if (decision != ApprovalDecision.APPROVE) {
                    results.put(call.id(),
                        "工具执行被%s: 已记录审批单 %s,请勿重试此工具"
                            .formatted(decision == ApprovalDecision.TIMEOUT
                                    ? "超时" : "人工拒绝", requestId));
                    continue;
                }
            }
            // 低危工具 / 已批准:交给真正的执行器
            ToolExecutionResult r = delegate.executeToolCalls(prompt,
                    chatResponseWithSingleCall(chatResponse, call));
            r.conversationHistory().stream()
                    .filter(ToolResponseMessage.class::isInstance)
                    .map(ToolResponseMessage.class::cast)
                    .flatMap(m -> m.getResponses().stream())
                    .forEach(resp -> results.putIfAbsent(resp.id(),
                            resp.textData()));
        }
        return rebuildResult(prompt, calls, results);
    }
}

审批登记器用 CompletableFuture 阻塞等待前端回调,fail-safe 是底线:任何非 APPROVE 路径都不放行:

java 复制代码
@Component
public class ApprovalRegistry {

    private final Map<String, PendingApproval> pending = new ConcurrentHashMap<>();

    public record PendingApproval(AssistantMessage.ToolCall call,
                                  CompletableFuture<ApprovalDecision> future,
                                  Instant deadline) {}

    public String register(AssistantMessage.ToolCall call) {
        String requestId = UUID.randomUUID().toString();
        pending.put(requestId, new PendingApproval(call,
                new CompletableFuture<>(), Instant.now().plus(Duration.ofMinutes(2))));
        // 通过 WebSocket/SSE 把"要调什么工具、什么参数"推给前端渲染审批按钮
        eventPublisher.publish(new ApprovalRequestEvent(requestId,
                call.name(), call.arguments()));
        return requestId;
    }

    public ApprovalDecision await(String requestId, Duration timeout) {
        try {
            return pending.get(requestId).future().get(timeout.toMillis(),
                    TimeUnit.MILLISECONDS);
        } catch (Exception e) {
            return ApprovalDecision.TIMEOUT;   // 超时不执行
        } finally {
            pending.remove(requestId);
        }
    }

    /** 前端回调入口 */
    public void complete(String requestId, ApprovalDecision decision) {
        PendingApproval p = pending.get(requestId);
        if (p != null) p.future().complete(decision);
    }
}

最后是执行端的幂等与状态机 。审批通过不等于执行成功------审批窗口内环境可能已变化。真正执行写操作时用条件更新 + 幂等键 + 状态机防止重复操作:

java 复制代码
@Transactional
public String restart(String service, String userId, String idempotencyKey) {
    // 1. 幂等键去重:同一审批单重复回调直接返回原结果
    Optional<OpsRecord> dup = opsRepo.findByIdempotencyKey(idempotencyKey);
    if (dup.isPresent()) return "操作已执行过(幂等拦截),审批单: " + idempotencyKey;

    // 2. 状态机条件更新:只允许从 IDLE/FAILED 进入 RESTARTING
    int updated = opsRepo.casTransition(service,
            Set.of(ServiceState.IDLE, ServiceState.FAILED),
            ServiceState.RESTARTING, userId, Instant.now());
    if (updated == 0) {
        return "服务当前状态不允许重启(可能已有其他操作在进行)";
    }
    // 3. Outbox 模式保证业务操作与审计记录最终一致
    opsRepo.save(new OpsRecord(service, userId, idempotencyKey, Instant.now()));
    outbox.publish(new ServiceRestartRequested(service, userId, idempotencyKey));
    return "重启指令已下发";
}

四、生产踩坑清单

这一套三防线落地过程中,真实踩过的坑比代码多得多:

  1. 把权限判断写在 Prompt 里。在 system prompt 里写"只有管理员才能调用重启工具"是自欺欺人------Prompt 注入一句话就能绕过。工具描述、参数 Schema 和 Prompt 都不能替代代码中的权限判断,权限必须在 Java 代码里做。
  2. ToolContext 忘传导致 NPE 而不是拒绝 。非 Web 入口(如定时任务、消息队列消费者)调 ChatClient 时容易漏掉 toolContext(),工具方法里取身份拿到 null。生产做法:工具入口统一断言身份存在,缺失直接返回拒绝文本,同时打告警日志。
  3. 审批等待阻塞虚拟线程,把平台线程拖死 。future.get() 阻塞在传统线程池上,几个并发审批就把池占满,整个 Agent 假死。解法:审批等待必须跑在虚拟线程上(spring.threads.virtual.enabled=true),或者干脆改造为完全异步------工具循环挂起、审批回调后恢复。
  4. 审批窗口内环境漂移。用户花 90 秒审批"扩容到 10 副本",期间另一个 SRE 已手动扩过。没有条件更新就会重复执行。所有写操作必须带状态机条件更新 + 幂等键,幂等键用审批单 requestId 贯穿。
  5. 流式模式下审批事件与 token 流交错。SSE 输出流里混入审批请求事件,前端解析炸掉。事件总线要分通道:token 流和审批事件用不同 event type,前端按类型分发渲染。
  6. 只做方法级校验,漏了参数级注入 。服务名白名单挡住了越权目标,但模型生成的 JSON 里塞进 {"serviceName":"api-gateway\"; rm -rf /"} 这类畸形值,透传给下游 CLI 类工具就是命令注入。所有最终落到 shell/SQL/路径的参数必须再过一遍各自的注入防线,不能因为过了白名单就放行。
  7. 审批超时语义没对齐。超时后既不放行也不明确告知模型,模型反复重试同一工具制造审批单风暴。超时返回文本要明确"该操作等待审批已超时,请勿重试,请向用户说明情况"。
  8. MCP HTTP Server 裸奔上线 。Spring AI 的 MCP Server 默认不带业务认证,/sse 和 /mcp 端点公开可达时,任何能连上的人都能触发工具。上线前必须:token 认证(自定义 Header 透传)→ @PreAuthorize 方法级校验 → 网络隔离(只在内网/服务网格内可达)。三件套缺一不可。
  9. 审计日志只记成功不记拒绝。排查"模型乱调用"时,拒绝记录才是关键证据。参数校验拒绝数、权限拒绝数、审批超时数、重复确认拦截数,都应该进 Micrometer 指标和审计表------不只是合规要求,更是你调优工具白名单和风险分级的数据来源。

五、总结:模型提出动作,系统掌握执行权

回顾这三道防线的分工:

  • 参数校验(防线一):白名单收敛爆炸半径、路径规范化防穿越、拒绝以工具结果形式回传让模型可自我修正;
  • 权限校验 (防线二):身份从认证上下文取、ToolContext 旁路传参、RBAC 方法级校验、场景化工具白名单实现读写分离与最小下发;
  • 人工确认(防线三):高危工具预览审批、fail-safe 超时不放行、状态机 + 条件更新 + 幂等键防重复执行。

这套设计背后的价值观只有一句话:**模型可以负责理解意图和提出下一步,但"谁能做、做什么、何时真正执行"的决定权,必须牢牢握在系统手里。**工具描述和 Prompt 永远是"建议",代码校验才是"法律"。

对 Java 工程师来说,这恰恰是最熟悉的领域------参数校验是 @Validated 的日常,权限是 Spring Security 的日常,幂等和状态机是分布式系统的日常。Agent 时代的工具调用可靠性工程,本质上就是把后端工程师十年积累的边界防御经验,平移到"模型是新的不可信调用方"这个新前提上。你不需要转 Python,你过去写的每一道防线,正是 AI Agent 落地最稀缺的能力。

参考资料:

  • Spring AI 2.0.x 官方文档:Tool Calling / MCP Client / MCP Server 安全模块

  • spring-projects/spring-ai discussion #4878: Tool Approval Strategy for Spring AI Tool Calling

  • Spring AI Community Playground Tutorial 11: Approve a Tool in Chat(HITL 风险分级与超时 fail-safe)

  • CSDN 热榜《AI Agent 工具调用如何更可靠?Spring AI + MCP 实战》

相关推荐
步行cgn1 小时前
RESTful 是什么?怎么记住它?
java
随性而行3601 小时前
企业微信 Webhook 接收不到消息?网络连通性、URL 签名验签与 API 返回错误码排查指南
java·网络·人工智能·后端·微信·企业微信
薛之谦_1 小时前
JD Hotkey 项目实战:京东开源的热点探测与秒杀系统
java·安全
yuzhiboyouye1 小时前
@EnumValue和@JsonValue
java
ebok.1 小时前
低代码平台如何集成 AI:从开发侧到运行侧的技术实践与京微智枢方案
人工智能·低代码·ai·rxjava
相隔一个图书馆的距离1 小时前
单测(三)Spring Boot 3.5.6 && @MockitoSpyBean / AOP / 循环依赖:静默吞错与运行期 NPE
java·spring boot·后端
软件技术NINI1 小时前
基于JavaWeb 汽车租聘
java·vue.js·spring boot·后端·python·毕业设计·毕设
yz_aiks1 小时前
RestTemplate 废弃后迁移 RestClient 实战:Spring Boot 4.2 升级指南
java·spring boot·后端·微服务·restclient
wuminyu1 小时前
JVM规范学习总结-JVM规范基本概念
java·linux·c语言·jvm·c++