
示例基线: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 配置
转载自 CSDN-专业IT技术社区
原文链接:https://blog.csdn.net/2403_86779341/article/details/168550000




