第六篇:Spring AI 实战:将 Java 业务接口封装成企业级 MCP Server
系列:《2026 可验证企业级 AI Agent 工程》
本文关键词:Spring AI、MCP Server、Java、ClickHouse、Tool Calling、企业级 Agent
文章目录
- [第六篇:Spring AI 实战:将 Java 业务接口封装成企业级 MCP Server](#第六篇:Spring AI 实战:将 Java 业务接口封装成企业级 MCP Server)
- 前言
- [一、为什么不能直接把 Service 暴露给 Agent?](#一、为什么不能直接把 Service 暴露给 Agent?)
- [二、创建 MCP Server 模块](#二、创建 MCP Server 模块)
- [三、添加 Spring AI MCP 依赖](#三、添加 Spring AI MCP 依赖)
- [四、配置 MCP Server](#四、配置 MCP Server)
- 五、设计工具返回对象
- 六、实现能源查询领域服务
- [七、使用 `@McpTool` 暴露查询工具](#七、使用
@McpTool暴露查询工具)- 八、工具描述为什么如此重要?
- 九、实现告警历史查询工具
- [十、使用 `@McpResource` 暴露只读资源](#十、使用
@McpResource暴露只读资源)- [十一、使用 `@McpPrompt` 封装分析模板](#十一、使用
@McpPrompt封装分析模板)- 十二、如何处理创建工单这类写操作?
- 十三、加入身份认证和权限控制
- [十四、不要让 Agent 直接传递 SQL](#十四、不要让 Agent 直接传递 SQL)
- 十五、工具异常应该如何返回?
- 十六、增加幂等性控制
- 十七、添加审计切面
- [十八、使用 MCP Client 验证工具](#十八、使用 MCP Client 验证工具)
- [十九、MCP Server 应该测试什么?](#十九、MCP Server 应该测试什么?)
- [1. Schema 测试](#1. Schema 测试)
- [2. 参数边界测试](#2. 参数边界测试)
- [3. 权限测试](#3. 权限测试)
- [4. 幂等测试](#4. 幂等测试)
- [5. Agent 行为测试](#5. Agent 行为测试)
- 二十、生产部署建议
- 二十一、常见开发误区
- [1. 一个 Tool 承担所有业务](#1. 一个 Tool 承担所有业务)
- [2. 返回数据库原始数据](#2. 返回数据库原始数据)
- [3. 通过 Prompt 控制权限](#3. 通过 Prompt 控制权限)
- [4. 写操作没有幂等键](#4. 写操作没有幂等键)
- [5. Tool 中重复实现业务逻辑](#5. Tool 中重复实现业务逻辑)
- [6. 忽略协议版本](#6. 忽略协议版本)
- 二十二、最终工程结构
- 总结
- [《AI Agent 如何自主规划任务:ReAct、Plan-and-Execute 与状态机实战》](#《AI Agent 如何自主规划任务:ReAct、Plan-and-Execute 与状态机实战》)
前言
上一篇介绍了 MCP 的协议原理。本篇开始进入工程实战:
使用 Spring Boot 和 Spring AI,把现有能源平台的查询、分析和工单能力封装为 MCP Server。
最终向 Agent 提供以下能力:
text
queryDeviceEnergy 查询设备能耗
queryAlarmHistory 查询告警历史
calculateEnergyBaseline 计算能耗基线
createMaintenanceOrder 创建检修工单
整体架构如下:
#mermaid-svg-f4NgyNlZOyEPklRr{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-f4NgyNlZOyEPklRr .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-f4NgyNlZOyEPklRr .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-f4NgyNlZOyEPklRr .error-icon{fill:#552222;}#mermaid-svg-f4NgyNlZOyEPklRr .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-f4NgyNlZOyEPklRr .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-f4NgyNlZOyEPklRr .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-f4NgyNlZOyEPklRr .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-f4NgyNlZOyEPklRr .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-f4NgyNlZOyEPklRr .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-f4NgyNlZOyEPklRr .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-f4NgyNlZOyEPklRr .marker{fill:#333333;stroke:#333333;}#mermaid-svg-f4NgyNlZOyEPklRr .marker.cross{stroke:#333333;}#mermaid-svg-f4NgyNlZOyEPklRr svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-f4NgyNlZOyEPklRr p{margin:0;}#mermaid-svg-f4NgyNlZOyEPklRr .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-f4NgyNlZOyEPklRr .cluster-label text{fill:#333;}#mermaid-svg-f4NgyNlZOyEPklRr .cluster-label span{color:#333;}#mermaid-svg-f4NgyNlZOyEPklRr .cluster-label span p{background-color:transparent;}#mermaid-svg-f4NgyNlZOyEPklRr .label text,#mermaid-svg-f4NgyNlZOyEPklRr span{fill:#333;color:#333;}#mermaid-svg-f4NgyNlZOyEPklRr .node rect,#mermaid-svg-f4NgyNlZOyEPklRr .node circle,#mermaid-svg-f4NgyNlZOyEPklRr .node ellipse,#mermaid-svg-f4NgyNlZOyEPklRr .node polygon,#mermaid-svg-f4NgyNlZOyEPklRr .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-f4NgyNlZOyEPklRr .rough-node .label text,#mermaid-svg-f4NgyNlZOyEPklRr .node .label text,#mermaid-svg-f4NgyNlZOyEPklRr .image-shape .label,#mermaid-svg-f4NgyNlZOyEPklRr .icon-shape .label{text-anchor:middle;}#mermaid-svg-f4NgyNlZOyEPklRr .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-f4NgyNlZOyEPklRr .rough-node .label,#mermaid-svg-f4NgyNlZOyEPklRr .node .label,#mermaid-svg-f4NgyNlZOyEPklRr .image-shape .label,#mermaid-svg-f4NgyNlZOyEPklRr .icon-shape .label{text-align:center;}#mermaid-svg-f4NgyNlZOyEPklRr .node.clickable{cursor:pointer;}#mermaid-svg-f4NgyNlZOyEPklRr .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-f4NgyNlZOyEPklRr .arrowheadPath{fill:#333333;}#mermaid-svg-f4NgyNlZOyEPklRr .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-f4NgyNlZOyEPklRr .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-f4NgyNlZOyEPklRr .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-f4NgyNlZOyEPklRr .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-f4NgyNlZOyEPklRr .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-f4NgyNlZOyEPklRr .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-f4NgyNlZOyEPklRr .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-f4NgyNlZOyEPklRr .cluster text{fill:#333;}#mermaid-svg-f4NgyNlZOyEPklRr .cluster span{color:#333;}#mermaid-svg-f4NgyNlZOyEPklRr div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-f4NgyNlZOyEPklRr .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-f4NgyNlZOyEPklRr rect.text{fill:none;stroke-width:0;}#mermaid-svg-f4NgyNlZOyEPklRr .icon-shape,#mermaid-svg-f4NgyNlZOyEPklRr .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-f4NgyNlZOyEPklRr .icon-shape p,#mermaid-svg-f4NgyNlZOyEPklRr .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-f4NgyNlZOyEPklRr .icon-shape .label rect,#mermaid-svg-f4NgyNlZOyEPklRr .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-f4NgyNlZOyEPklRr .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-f4NgyNlZOyEPklRr .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-f4NgyNlZOyEPklRr :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 用户
AI Agent
MCP Client
Spring AI MCP Server
能源领域服务
ClickHouse
达梦数据库
工单系统
本文示例采用 Spring AI 2.x 的注解式 MCP API。不同版本的依赖名称和配置可能存在差异,实际开发时应以项目使用的 Spring AI BOM 为准。
一、为什么不能直接把 Service 暴露给 Agent?
假设系统中已经存在以下业务方法:
java
public List<EnergyData> queryEnergy(
String deviceId,
LocalDateTime startTime,
LocalDateTime endTime) {
// 查询ClickHouse
}
最简单的做法,是直接把这个方法注册成工具。
但生产环境还需要考虑:
- 用户是否有权查询该设备;
- 时间范围是否合理;
- 返回数据是否过大;
- 是否包含敏感字段;
- 调用失败后能否重试;
- 查询是否需要限流;
- 工具行为是否可审计;
- 返回结构是否便于模型理解。
因此,推荐在业务服务外增加一层 MCP Adapter:
text
MCP Tool
↓ 参数校验、权限判断、结果裁剪
Application Service
↓ 业务逻辑
Repository / Remote Client
↓
数据库与外部系统
MCP 层负责协议适配,业务规则仍然由领域服务负责。
二、创建 MCP Server 模块
建议在多模块项目中单独创建 MCP Server:
text
platform-energy-parent
├── core
│ ├── energy-base
│ └── energy-domain
├── platform-energy-manage
├── platform-energy-task
└── platform-energy-mcp-server
├── src/main/java
│ └── com/smalleel/mcp
│ ├── tool
│ ├── resource
│ ├── service
│ ├── security
│ └── dto
└── src/main/resources
└── application.yml
单独划分模块有三个好处:
- 不需要把整个管理后台暴露给 Agent;
- 可以独立部署、扩容和限流;
- 可以单独管理工具权限与审计规则。
三、添加 Spring AI MCP 依赖
使用 WebMVC 和 Streamable HTTP:
xml
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-bom</artifactId>
<version>${spring-ai.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-mcp-server-webmvc</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
</dependencies>
如果项目使用 WebFlux,可以改为:
xml
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-mcp-server-webflux</artifactId>
</dependency>
WebMVC 更适合现有同步业务服务,WebFlux 更适合大量异步 I/O 场景。
四、配置 MCP Server
application.yml 示例:
yaml
server:
port: 8090
spring:
application:
name: platform-energy-mcp-server
ai:
mcp:
server:
name: energy-mcp-server
version: 1.0.0
type: SYNC
protocol: STREAMABLE
annotation-scanner:
enabled: true
常见传输模式:
| 模式 | 配置 | 适用场景 |
|---|---|---|
| STDIO | stdio=true |
本地工具、IDE 插件 |
| SSE | protocol=SSE |
兼容旧版客户端 |
| Streamable HTTP | protocol=STREAMABLE |
Web 服务与远程调用 |
| Stateless HTTP | protocol=STATELESS |
云原生和水平扩容 |
如果当前 Java SDK 尚未完整支持最新 MCP 规范,应优先选择客户端和服务端都支持的协议模式。
五、设计工具返回对象
模型更适合处理结构化结果,而不是数据库实体。
java
public record DeviceEnergyResult(
String deviceId,
String deviceName,
String startTime,
String endTime,
BigDecimal totalEnergy,
BigDecimal baselineEnergy,
BigDecimal deviationRate,
String unit,
String dataQuality,
List<AbnormalPeriod> abnormalPeriods
) {
}
异常时段:
java
public record AbnormalPeriod(
String startTime,
String endTime,
BigDecimal averagePower,
BigDecimal deviationRate
) {
}
返回对象应该做到:
- 字段名称明确;
- 数值包含单位;
- 时间包含时区;
- 明确数据质量;
- 避免无关数据库字段;
- 控制集合最大长度。
不推荐直接返回:
java
List<Map<String, Object>>
因为模型无法稳定理解每个字段的业务含义。
六、实现能源查询领域服务
MCP Tool 不应该直接编写 SQL。
java
@Service
public class DeviceEnergyService {
private final EnergyRepository energyRepository;
private final DeviceRepository deviceRepository;
public DeviceEnergyService(
EnergyRepository energyRepository,
DeviceRepository deviceRepository) {
this.energyRepository = energyRepository;
this.deviceRepository = deviceRepository;
}
public DeviceEnergyResult queryDeviceEnergy(
String deviceId,
LocalDateTime startTime,
LocalDateTime endTime,
String granularity) {
validateTimeRange(startTime, endTime);
Device device = deviceRepository.findById(deviceId)
.orElseThrow(() ->
new IllegalArgumentException("设备不存在:" + deviceId));
EnergyStatistics statistics =
energyRepository.aggregate(
deviceId,
startTime,
endTime,
granularity
);
BigDecimal baseline =
energyRepository.calculateBaseline(
deviceId,
startTime,
endTime
);
BigDecimal deviationRate =
calculateDeviation(statistics.totalEnergy(), baseline);
return new DeviceEnergyResult(
device.id(),
device.name(),
startTime.toString(),
endTime.toString(),
statistics.totalEnergy(),
baseline,
deviationRate,
"kWh",
statistics.dataQuality(),
statistics.abnormalPeriods()
);
}
private void validateTimeRange(
LocalDateTime startTime,
LocalDateTime endTime) {
if (!endTime.isAfter(startTime)) {
throw new IllegalArgumentException(
"结束时间必须晚于开始时间"
);
}
if (Duration.between(startTime, endTime).toDays() > 31) {
throw new IllegalArgumentException(
"单次查询时间范围不能超过31天"
);
}
}
private BigDecimal calculateDeviation(
BigDecimal current,
BigDecimal baseline) {
if (baseline == null
|| baseline.compareTo(BigDecimal.ZERO) == 0) {
return BigDecimal.ZERO;
}
return current.subtract(baseline)
.divide(baseline, 4, RoundingMode.HALF_UP);
}
}
这里将时间范围限制在 31 天,避免 Agent 一次查询数年的原始数据。
七、使用 @McpTool 暴露查询工具
java
@Component
public class EnergyMcpTools {
private final DeviceEnergyService deviceEnergyService;
public EnergyMcpTools(
DeviceEnergyService deviceEnergyService) {
this.deviceEnergyService = deviceEnergyService;
}
@McpTool(
name = "queryDeviceEnergy",
title = "查询设备能耗",
description = """
查询指定设备在某个时间范围内的能耗统计和异常时段。
适用于:
1. 查询设备总能耗;
2. 对比历史能耗基线;
3. 定位能耗异常时段。
不适用于:
1. 查询设备告警;
2. 修改设备配置;
3. 查询超过31天的原始数据。
""",
generateOutputSchema = true,
annotations = @McpTool.McpAnnotations(
readOnlyHint = true,
destructiveHint = false,
idempotentHint = true,
openWorldHint = false
)
)
public DeviceEnergyResult queryDeviceEnergy(
@McpToolParam(
description = "设备唯一编号,例如AC-001",
required = true)
String deviceId,
@McpToolParam(
description = "ISO-8601格式开始时间",
required = true)
String startTime,
@McpToolParam(
description = "ISO-8601格式结束时间",
required = true)
String endTime,
@McpToolParam(
description = "聚合粒度:minute、hour或day",
required = true)
String granularity) {
return deviceEnergyService.queryDeviceEnergy(
deviceId,
LocalDateTime.parse(startTime),
LocalDateTime.parse(endTime),
validateGranularity(granularity)
);
}
private String validateGranularity(String granularity) {
Set<String> allowed =
Set.of("minute", "hour", "day");
if (!allowed.contains(granularity)) {
throw new IllegalArgumentException(
"granularity只能是minute、hour或day"
);
}
return granularity;
}
}
generateOutputSchema = true 会为非基础类型返回值生成输出 Schema,帮助客户端和模型理解返回结构。
工具注解中的 Hint 需要正确填写:
text
readOnlyHint 是否只读
destructiveHint 是否可能产生破坏性修改
idempotentHint 重复调用是否具有相同结果
openWorldHint 是否会访问开放外部环境
但这些只是提示,不能代替服务端安全控制。
八、工具描述为什么如此重要?
下面这种描述几乎没有价值:
java
@McpTool(description = "查询数据")
模型无法知道:
- 查询什么数据;
- 什么时候应该调用;
- 参数如何填写;
- 与其他工具有什么区别。
一份高质量描述应包含:
text
工具做什么
适合哪些问题
不适合哪些问题
参数格式
返回内容
边界条件
工具描述越明确,Agent 越不容易:
- 选错工具;
- 重复调用;
- 构造错误参数;
- 在信息不足时盲目调用。
九、实现告警历史查询工具
java
public record AlarmResult(
String alarmId,
String deviceId,
String alarmType,
String alarmLevel,
String alarmTime,
String recoveryTime,
String status
) {
}
java
@Component
public class AlarmMcpTools {
private final AlarmService alarmService;
public AlarmMcpTools(AlarmService alarmService) {
this.alarmService = alarmService;
}
@McpTool(
name = "queryAlarmHistory",
description = """
查询指定设备在给定时间范围内的告警记录。
最多返回100条,按告警时间倒序排列。
如果需要分析能耗,请使用queryDeviceEnergy。
""",
generateOutputSchema = true,
annotations = @McpTool.McpAnnotations(
readOnlyHint = true,
destructiveHint = false,
idempotentHint = true,
openWorldHint = false
)
)
public List<AlarmResult> queryAlarmHistory(
@McpToolParam(
description = "设备唯一编号",
required = true)
String deviceId,
@McpToolParam(
description = "ISO-8601格式开始时间",
required = true)
String startTime,
@McpToolParam(
description = "ISO-8601格式结束时间",
required = true)
String endTime) {
return alarmService.query(
deviceId,
LocalDateTime.parse(startTime),
LocalDateTime.parse(endTime),
100
);
}
}
必须在服务端设置返回数量上限,不能允许模型自行决定无限返回。
十、使用 @McpResource 暴露只读资源
设备档案更适合作为 Resource,而不是 Tool:
java
@Component
public class DeviceResources {
private final DeviceService deviceService;
private final ObjectMapper objectMapper;
public DeviceResources(
DeviceService deviceService,
ObjectMapper objectMapper) {
this.deviceService = deviceService;
this.objectMapper = objectMapper;
}
@McpResource(
uri = "energy://device/{deviceId}/profile",
name = "device-profile",
title = "设备档案",
description = "读取指定设备的基础档案和技术参数",
mimeType = "application/json"
)
public String getDeviceProfile(String deviceId)
throws JsonProcessingException {
DeviceProfile profile =
deviceService.getSafeProfile(deviceId);
return objectMapper.writeValueAsString(profile);
}
}
注意,getSafeProfile() 应提前移除:
- 设备控制密码;
- 内部网络地址;
- 数据源账号;
- 不允许当前用户查看的字段。
十一、使用 @McpPrompt 封装分析模板
可以将企业分析流程封装为 MCP Prompt:
java
@Component
public class EnergyPrompts {
@McpPrompt(
name = "analyze-energy-anomaly",
title = "分析设备能耗异常",
description = "根据能耗、告警和历史基线分析异常原因"
)
public GetPromptResult analyzeEnergyAnomaly(
@McpArg(
name = "deviceId",
description = "设备唯一编号",
required = true)
String deviceId,
@McpArg(
name = "date",
description = "需要分析的日期",
required = true)
String date) {
String message = """
请分析设备 %s 在 %s 的能耗异常。
执行步骤:
1. 使用queryDeviceEnergy查询能耗及历史基线;
2. 使用queryAlarmHistory查询同期告警;
3. 将事实、推断和待确认事项分开;
4. 每个结论必须说明数据依据;
5. 数据不足时不得虚构原因。
""".formatted(deviceId, date);
return GetPromptResult.builder(
List.of(new PromptMessage(
Role.USER,
TextContent.builder(message).build()
))
).description("设备能耗异常分析流程").build();
}
}
Prompt 适合封装业务分析流程,但安全规则仍然应该放在代码和权限系统中。
十二、如何处理创建工单这类写操作?
创建工单不是只读操作,必须更加谨慎。
java
public record CreateOrderResult(
String orderId,
String status,
boolean created,
String message
) {
}
java
@Component
public class MaintenanceOrderTools {
private final MaintenanceOrderService orderService;
public MaintenanceOrderTools(
MaintenanceOrderService orderService) {
this.orderService = orderService;
}
@McpTool(
name = "createMaintenanceOrder",
description = """
为指定设备创建检修工单。
调用前必须确认:
1. 用户已经明确同意创建;
2. deviceId和故障描述已经确认;
3. idempotencyKey已经生成。
不得根据未经验证的模型推断自动创建工单。
""",
generateOutputSchema = true,
annotations = @McpTool.McpAnnotations(
readOnlyHint = false,
destructiveHint = false,
idempotentHint = true,
openWorldHint = false
)
)
public CreateOrderResult createMaintenanceOrder(
@McpToolParam(
description = "设备唯一编号",
required = true)
String deviceId,
@McpToolParam(
description = "经过确认的故障描述",
required = true)
String faultDescription,
@McpToolParam(
description = "用户确认标识,必须为true",
required = true)
boolean confirmed,
@McpToolParam(
description = "幂等键,相同业务请求必须使用相同值",
required = true)
String idempotencyKey) {
if (!confirmed) {
throw new IllegalArgumentException(
"创建工单前必须获得用户确认"
);
}
return orderService.create(
deviceId,
faultDescription,
idempotencyKey
);
}
}
需要强调的是:
confirmed=true不能作为唯一的安全证明。
因为该值可能由模型自行生成。正式系统还应该由 Host 或审批系统签发不可伪造的确认凭证,并由 MCP Server 验证:
text
confirmationToken
userId
operation
resourceId
expiredAt
signature
十三、加入身份认证和权限控制
工具被发现,不代表调用者有权执行。
可以使用 Spring Security 将 MCP Server 配置为 OAuth2 Resource Server:
java
@Configuration
@EnableWebSecurity
public class McpSecurityConfiguration {
@Bean
SecurityFilterChain securityFilterChain(
HttpSecurity http) throws Exception {
return http
.authorizeHttpRequests(authorize -> authorize
.requestMatchers("/actuator/health")
.permitAll()
.requestMatchers("/mcp/**")
.authenticated()
.anyRequest()
.denyAll()
)
.oauth2ResourceServer(oauth2 ->
oauth2.jwt(Customizer.withDefaults())
)
.build();
}
}
配置 JWT 签发方:
yaml
spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: https://auth.example.com
除此之外,还需要执行资源级权限校验:
java
public void checkDevicePermission(
String userId,
String deviceId,
String permission) {
if (!permissionService.hasDevicePermission(
userId,
deviceId,
permission)) {
throw new AccessDeniedException(
"无权访问设备:" + deviceId
);
}
}
只校验"用户已经登录"是不够的,还必须校验:
text
用户是否属于当前租户
用户是否有权访问当前设备
用户是否拥有工具对应权限
当前时间是否允许执行
操作是否需要二次审批
十四、不要让 Agent 直接传递 SQL
下面的工具设计存在严重风险:
java
@McpTool
public List<Map<String, Object>> executeSql(String sql) {
return jdbcTemplate.queryForList(sql);
}
即使增加"只允许 SELECT"的 Prompt,也无法真正保证安全。
风险包括:
- SQL 注入;
- 越权查询;
- 敏感表泄露;
- 超大结果集;
- 慢查询拖垮数据库;
- 方言绕过;
- Prompt Injection 诱导查询。
正确方式是暴露业务语义:
java
queryDeviceEnergy(
deviceId,
startTime,
endTime,
granularity
)
由服务端根据受控参数构造 SQL。
十五、工具异常应该如何返回?
根据 Spring AI MCP 注解约定,RuntimeException 可以转换为工具错误结果,模型能够读取错误并决定是否修正参数或重试。
例如:
java
if (!deviceExists(deviceId)) {
throw new IllegalArgumentException(
"设备不存在,请先确认deviceId"
);
}
错误信息应满足:
- 对模型具有可操作性;
- 不暴露堆栈信息;
- 不暴露 SQL;
- 不暴露服务器路径;
- 不包含密码或 Token。
不推荐:
text
NullPointerException at EnergyMapper.java:128
SQLState 42000
jdbc:clickhouse://192.168.x.x...
推荐:
json
{
"errorCode": "DEVICE_NOT_FOUND",
"message": "设备不存在,请先查询设备档案确认deviceId",
"retryable": false
}
对于超时和临时网络异常,可以明确告诉 Agent 是否允许重试。
十六、增加幂等性控制
查询工具天然接近幂等,但写入工具必须主动设计。
例如创建工单:
java
@Transactional
public CreateOrderResult create(
String deviceId,
String description,
String idempotencyKey) {
Optional<MaintenanceOrder> existing =
orderRepository.findByIdempotencyKey(
idempotencyKey
);
if (existing.isPresent()) {
return toResult(existing.get(), false);
}
MaintenanceOrder order =
orderRepository.save(
MaintenanceOrder.create(
deviceId,
description,
idempotencyKey
)
);
return toResult(order, true);
}
Agent 可能因为以下原因重复调用:
- 模型误判;
- 请求超时;
- 客户端重试;
- 网络中断;
- 任务恢复。
因此,写操作不能假设只会执行一次。
十七、添加审计切面
可以通过注解和 AOP 统一记录工具调用。
java
@Aspect
@Component
public class McpToolAuditAspect {
private static final Logger log =
LoggerFactory.getLogger(McpToolAuditAspect.class);
@Around("@annotation(mcpTool)")
public Object audit(
ProceedingJoinPoint point,
McpTool mcpTool) throws Throwable {
long start = System.currentTimeMillis();
try {
Object result = point.proceed();
log.info(
"MCP tool success, tool={}, cost={}ms",
mcpTool.name(),
System.currentTimeMillis() - start
);
return result;
} catch (Exception exception) {
log.warn(
"MCP tool failed, tool={}, cost={}ms, type={}",
mcpTool.name(),
System.currentTimeMillis() - start,
exception.getClass().getSimpleName()
);
throw exception;
}
}
}
正式审计日志至少应包含:
text
TraceId
用户ID
租户ID
Agent标识
MCP Client标识
工具名称
脱敏后的参数摘要
权限判断结果
执行状态
耗时
结果数量
错误码
不要把密码、Token 和完整敏感数据写入日志。
十八、使用 MCP Client 验证工具
创建测试客户端时添加:
xml
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-mcp-client</artifactId>
</dependency>
配置远程连接:
yaml
spring:
ai:
mcp:
client:
streamable-http:
connections:
energy-server:
url: http://localhost:8090
将 MCP 工具交给 ChatClient:
java
@Bean
CommandLineRunner testEnergyAgent(
ChatClient.Builder builder,
ToolCallbackProvider mcpTools) {
return args -> {
ChatClient chatClient = builder.build();
String response = chatClient.prompt()
.system("""
你是一名能源分析助手。
必须基于工具返回的数据回答。
数据不足时明确说明,不得虚构。
""")
.user("""
查询设备AC-001昨天的能耗,
与历史基线比较并指出异常时段。
""")
.tools(mcpTools)
.call()
.content();
System.out.println(response);
};
}
此时执行过程大致为:
text
用户提出问题
→ 模型选择queryDeviceEnergy
→ MCP Client发送tools/call
→ MCP Server执行Java服务
→ 返回结构化能耗结果
→ 模型根据结果生成答案
十九、MCP Server 应该测试什么?
仅测试 Java 方法能够返回结果是不够的。
1. Schema 测试
检查:
- 工具名称是否唯一;
- 参数是否完整;
- 必填字段是否正确;
- 描述是否清晰;
- 输出 Schema 是否稳定。
2. 参数边界测试
text
设备编号为空
设备不存在
开始时间晚于结束时间
时间范围超过31天
粒度不合法
非法时区
超长文本
3. 权限测试
text
未登录用户
跨租户访问
无设备权限
普通用户调用写入工具
审批凭证过期
伪造确认信息
4. 幂等测试
同一个 idempotencyKey 连续创建两次工单,系统只能产生一条业务记录。
5. Agent 行为测试
还需要使用真实模型验证:
text
是否会选对工具?
是否会重复调用?
是否会虚构工具结果?
工具失败后是否会无限重试?
执行写操作前是否会请求确认?
二十、生产部署建议
生产级 MCP Server 建议加入以下保护:
text
API Gateway
├── 身份认证
├── 工具级授权
├── 请求限流
├── Body大小限制
├── 超时与熔断
├── 敏感参数脱敏
└── 审计日志
工具级限制示例:
| 工具 | 超时 | 最大频率 | 是否确认 |
|---|---|---|---|
queryDeviceEnergy |
5秒 | 100次/分钟 | 否 |
queryAlarmHistory |
5秒 | 100次/分钟 | 否 |
calculateEnergyBaseline |
15秒 | 20次/分钟 | 否 |
createMaintenanceOrder |
10秒 | 5次/分钟 | 是 |
stopDevice |
禁止自动调用 | 0 | 强审批 |
还应配置:
- 数据库查询超时;
- 最大返回记录数;
- 最大时间跨度;
- 工具调用并发数;
- Agent 单任务调用预算;
- 重试次数上限;
- 熔断和降级策略。
二十一、常见开发误区
1. 一个 Tool 承担所有业务
text
executeEnergyOperation
模型难以判断它具体做什么,也难以进行精细权限控制。
应按业务动作拆分工具。
2. 返回数据库原始数据
几万条时序记录直接进入模型上下文,会增加成本并降低推理质量。
应先完成聚合和异常检测。
3. 通过 Prompt 控制权限
"没有权限时不要调用"不是安全机制。
权限必须在 MCP Server 中校验。
4. 写操作没有幂等键
网络重试可能造成重复工单、重复通知甚至重复控制指令。
5. Tool 中重复实现业务逻辑
MCP Tool 应作为适配层调用现有领域服务,不能形成第二套业务逻辑。
6. 忽略协议版本
MCP 规范、MCP Java SDK 和 Spring AI 的版本不一定同步。客户端与服务端必须进行兼容性验证。
二十二、最终工程结构
text
platform-energy-mcp-server
├── tool
│ ├── EnergyMcpTools.java
│ ├── AlarmMcpTools.java
│ └── MaintenanceOrderTools.java
├── resource
│ └── DeviceResources.java
├── prompt
│ └── EnergyPrompts.java
├── service
│ ├── DeviceEnergyService.java
│ └── MaintenanceOrderService.java
├── security
│ ├── McpSecurityConfiguration.java
│ └── DevicePermissionService.java
├── audit
│ └── McpToolAuditAspect.java
└── dto
├── DeviceEnergyResult.java
├── AlarmResult.java
└── CreateOrderResult.java
核心设计原则是:
text
MCP层负责协议适配
领域层负责业务规则
数据层负责数据访问
安全层负责身份与权限
审计层负责调用追踪
总结
使用 Spring AI 构建 MCP Server,最简单的部分是添加 @McpTool。
真正决定系统能否进入生产环境的是:
- 工具边界是否清晰;
- 参数是否受到约束;
- 返回结果是否结构化;
- 权限是否在服务端校验;
- 写操作是否需要确认;
- 是否具备幂等、限流和审计;
- 是否与现有领域服务正确解耦。
企业级 MCP Server 不只是一个"让大模型调用 Java 方法"的接口,而是:
AI Agent 与企业核心业务系统之间的一层安全、标准、可治理的能力网关。
下一篇:
《AI Agent 如何自主规划任务:ReAct、Plan-and-Execute 与状态机实战》
参考资料: