MSTcheng头像
AI Agent 工具调用如何更可靠?Spring AI + MCP 实战:参数校验、权限校验与人工确认封面图

AI Agent 工具调用如何更可靠?Spring AI + MCP 实战:参数校验、权限校验与人工确认

在这里插入图片描述

示例基线:Java 17+、Spring Boot 4.0.x、Spring AI 2.0.1。模型 API 与 MCP Server 分开配置;敏感操作只生成待确认方案,不让模型直接执行。

写在前面

让大模型“会调用工具”不算太难,真正进入业务系统后,大家很快会遇到几个更现实的问题:模型传错设备编号怎么办?当前用户没有权限怎么办?模型把“重启设备”理解成“立刻执行”怎么办?失败重试会不会把同一条指令执行两遍?

这篇文章不再堆 Agent 名词,而是搭一条最小但完整的工具调用链:Spring AI 负责对话和工具编排,MCP 负责标准化暴露设备查询能力;服务端对参数做二次校验;高风险操作只生成待确认方案,最终由经过认证的人工操作确认后执行。

先明确一条底线:MCP 解决工具如何被发现和调用,不会自动替业务做身份认证、权限判断或人工审批。 Spring AI 的 MCP HTTP 服务端默认也不会替你加上鉴权。上线前必须把这些边界设计到应用或网关里。

一、整体结构:把“建议”和“执行”分开

用户
 │  POST /api/chat
 ▼
Agent API(Spring Boot + Spring AI)
 │  模型选择工具,并给出参数
 ▼
MCP Client ───── Streamable HTTP ─────► Device MCP Server
                                              │
                                只读查询 / 生成操作方案
                                              │
用户看到待确认卡片 ◄─────────────────────────┘
 │  已登录的用户点击确认
 ▼
审批 API:权限检查 + 状态检查 + 幂等键
 │
 ▼
设备控制服务

本文用两个 Spring Boot 应用,方便看清 MCP 的边界:

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,以免 Spring AI 模块版本不一致。

<dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>org.springframework.ai</groupId>
            <artifactId>spring-ai-bom</artifactId>
            <version>2.0.1</version>
            <type>pom</type>
            <scope>import</scope>
        </dependency>
    </dependencies>
</dependencyManagement>

device-mcp-server 的依赖:

两个模块的 pom.xml 都使用 Spring Boot 4.0.x parent,并设置 Java 17:

<parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>4.0.0</version>
    <relativePath/>
</parent>
<properties>
    <java.version>17</java.version>
</properties>
<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-webmvc</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.ai</groupId>
        <artifactId>spring-ai-starter-mcp-server-webmvc</artifactId>
    </dependency>
</dependencies>

agent-api 的依赖:

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-webmvc</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-security</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.ai</groupId>
        <artifactId>spring-ai-starter-model-openai</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.ai</groupId>
        <artifactId>spring-ai-starter-mcp-client</artifactId>
    </dependency>
</dependencies>

这里用 OpenAI 兼容的 Chat Model 做示例;换成其他 Spring AI 支持的模型时,工具边界和 MCP Client 配置思路不变。API Key 放环境变量或密钥系统,不要提交到 Git。

三、做一个 MCP Server:只暴露必要能力

服务端用 @McpTool 声明工具,Spring AI 会据此生成 MCP 工具定义和参数 Schema。描述要写清楚用途、输入和副作用边界,因为它既给模型看,也给维护者看。

package com.example.device;

import org.springframework.ai.mcp.annotation.McpTool;
import org.springframework.ai.mcp.annotation.McpToolParam;
import org.springframework.stereotype.Component;

@Component
public class DeviceTools {
    private final DeviceService deviceService;

    public DeviceTools(DeviceService deviceService) {
        this.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("设备编号格式不合法");
        }
    }
}

DeviceStatus 和 RestartPlan 是普通 Java record,例如:

public record DeviceStatus(
        String deviceId,
        String state,
        String lastSeenAt,
        Double temperature
) {}

public record RestartPlan(
        java.util.UUID actionId,
        String deviceId,
        String reason,
        String impact,
        boolean requiresHumanApproval
) {}

为了让调用关系更直观,下面用内存 Map 模拟设备目录和待审批记录。它只适合本地演示;正式环境需要将待审批动作保存到数据库,并设置过期时间、租户字段和审计信息。

package com.example.device;

import java.time.Instant;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import org.springframework.stereotype.Service;

@Service
public class DeviceService {
    private final Map<UUID, RestartPlan> pending = new ConcurrentHashMap<>();

    public DeviceStatus getStatus(String deviceId) {
        if (!"pump-017".equals(deviceId)) {
            throw new IllegalArgumentException("设备不存在");
        }
        return new DeviceStatus(deviceId, "RUNNING", Instant.now().toString(), 67.2);
    }

    public RestartPlan createPendingRestart(String deviceId, String reason) {
        if (!"pump-017".equals(deviceId)) {
            throw new IllegalArgumentException("设备不存在");
        }
        RestartPlan plan = new RestartPlan(
                UUID.randomUUID(), deviceId, reason,
                "重启可能造成短时停机;需要值班人员确认", true);
        pending.put(plan.actionId(), plan);
        return plan;
    }

    public RestartPlan findPending(UUID actionId) {
        return pending.get(actionId);
    }
}

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。HTTP MCP 端点默认不会帮你做认证授权;如果部署到网络环境,要在 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 会话:

package com.example.agent;

import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.tool.ToolCallbackProvider;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class AiConfiguration {
    @Bean
    ChatClient deviceChatClient(
            ChatClient.Builder builder,
            ToolCallbackProvider mcpTools) {
        return builder
                .defaultSystem("""
                    你是设备运维助手。
                    查询设备时,只使用已提供的只读工具。
                    用户提出重启请求时,只生成重启方案并说明需要人工确认;
                    不得声称设备已经重启,也不得把工具参数当作用户权限证明。
                    """)
                .defaultTools(mcpTools)
                .build();
    }
}

聊天接口只负责把用户问题交给 Agent:

public record ChatRequest(String message) {}
public record ChatResponse(String answer) {}

@RestController
@RequestMapping("/api/chat")
public class ChatController {
    private final ChatClient chatClient;

    public ChatController(ChatClient deviceChatClient) {
        this.chatClient = deviceChatClient;
    }

    @PostMapping
    public ChatResponse chat(@RequestBody ChatRequest request) {
        String answer = chatClient.prompt()
                .user(request.message())
                .call()
                .content();
        return new ChatResponse(answer);
    }
}

用户问“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 作为普通工具参数让模型传过去。

七、人工确认与幂等:确认接口才是真正的执行入口

高风险工具最好拆成“准备”和“执行”两个阶段:

  1. Agent 调用 prepare_device_restart,得到待审批 actionId、设备、原因、影响范围和风险提示;
  2. 前端将方案展示给用户;
  3. 用户点击确认,携带自己的登录凭证调用审批接口;
  4. 服务端校验权限、待审批状态、过期时间和一次性幂等键;
  5. 执行设备动作并记录操作人、时间、结果和关联请求 ID。

不要设计成模型调用 restart_device() 后,再让模型在回答里“询问是否确认”。此时副作用已经发生,确认只是文字。

审批存储建议用数据库状态机,而不是只放在单机内存里:

PENDING ──确认并成功──► EXECUTED
   │
   ├──过期──────────► EXPIRED
   └──执行失败──────► FAILED(可按策略人工重试)

状态更新可用条件更新保证同一 action 只被一个请求领取:

UPDATE device_action
SET status = 'APPROVING', approved_by = :operator, approved_at = now()
WHERE id = :actionId
  AND status = 'PENDING'
  AND expires_at > now();

只有更新行数为 1 才继续执行。与设备网关交互时继续传递 action ID 作为幂等键,避免网络超时后客户端重试造成重复操作。对于需要可靠恢复的流程,可以在同一数据库事务中写入 Outbox,再由工作进程执行命令,并记录成功或失败;不要在事务已提交但设备调用未完成时假装它已经成功。

八、一次本地调用怎么验证

先启动 MCP Server,再启动 Agent API:

# 终端一:启动 device-mcp-server
cd device-mcp-server
./mvnw spring-boot:run

# 终端二:配置模型密钥并启动 agent-api
export OPENAI_API_KEY="你的模型 API Key"
cd agent-api
./mvnw spring-boot:run

提问查询:

curl -X POST http://localhost:8080/api/chat \
  -H 'Content-Type: application/json' \
  -d '{"message":"查询 pump-017 的状态和最近温度"}'

提问重启:

curl -X POST http://localhost:8080/api/chat \
  -H 'Content-Type: application/json' \
  -d '{"message":"pump-017 温度异常,帮我重启它"}'

第二个请求应返回方案和确认提示;它不应调用设备控制接口。真正确认必须由已登录且有审批权限的用户调用 /api/actions/{actionId}/confirm。演示项目中的设备服务可以先用 mock 实现,切换真实设备前先检查权限和幂等流程。

九、上线前的观测与安全清单

每次工具调用至少记录 traceId、会话 ID、工具名、调用人或服务身份、参数校验结果、执行耗时、审批 action ID 和结果状态。生产日志默认不要记录 API Key、访问令牌、完整敏感参数或不必要的对话原文。

建议把以下指标接入监控:

  • MCP 连接成功率、工具发现数量和调用延迟;
  • 工具参数校验拒绝数、权限拒绝数和超时数;
  • 待审批数量、过期数量、重复确认拦截数;
  • 设备控制成功率、失败重试数和 Outbox 积压量。

对于公开可达的 MCP HTTP 服务,先完成身份认证、授权和网络隔离再上线。只把当前请求真正需要的工具交给模型;读写能力分开,危险操作默认禁用或走审批。工具描述、参数 Schema 和 Prompt 都不能替代代码中的权限判断。

总结

这次实战的重点不是让 Agent 多调用几个工具,而是把调用边界设计清楚:

  • Spring AI 通过 ToolCallbackProvider 使用 MCP Server 暴露的工具;
  • MCP Schema 帮助模型理解参数,业务服务仍需独立校验;
  • 用户身份从认证上下文获取,不接受模型自报身份;
  • 只读查询可以自动执行,高风险操作先预览,再由有权限的人确认;
  • 执行端用状态机、条件更新和幂等键防止重复操作;
  • MCP HTTP 服务默认不自带业务认证,部署前要放到可信安全边界后面。

这样做之后,模型可以负责理解意图和提出下一步,但系统仍然掌握“谁能做、做什么、何时真正执行”的决定权。Agent 才能从演示对话走向可审计、可控的业务集成。

参考资料


转载自 CSDN-专业IT技术社区

原文链接:https://blog.csdn.net/2403_86779341/article/details/168550000

文章来源转载

评论

评论列表

微信小程序
QQ小程序

关于作者

点赞数:0
关注数:0
粉丝:0
文章:0
关注标签:0
加入于:--