欢迎光临
我们一直在努力

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

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

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

  • Agent 调用 prepare_device_restart,得到待审批 actionId、设备、原因、影响范围和风险提示;
  • 前端将方案展示给用户;
  • 用户点击确认,携带自己的登录凭证调用审批接口;
  • 服务端校验权限、待审批状态、过期时间和一次性幂等键;
  • 执行设备动作并记录操作人、时间、结果和关联请求 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 才能从演示对话走向可审计、可控的业务集成。

    参考资料

    • Spring AI 2.0.x Getting Started 与版本兼容说明
    • Spring AI MCP Client Boot Starter
    • Spring AI MCP Server Boot Starter 与安全说明
    • Spring AI MCP Server Annotations
    • Spring AI ChatClient API 与工具注册
    • Spring AI OpenAI Chat 配置

    赞(0)
    未经允许不得转载:171主机测评 » AI Agent 工具调用如何更可靠?Spring AI + MCP 实战:参数校验、权限校验与人工确认
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址