系列:Spring AI + MCP------大数据智能运维实战
本篇目标:把教学数据封装成可发现、可调用、可检查结果的 MCP 工具,回答"有哪些节点,分别运行什么组件"。
技术基线:Java 17、Spring Boot 4.1.0、Spring AI 2.0.1、Maven 3.6.3+;配套调用脚本使用 Python 3 标准库。
案例说明:人物、平台、节点和数据均为虚构教学设定。示例独立设计,不对应任何真实生产系统。
1. 小周终于可以直接问"有哪些节点"了
上一篇,小林准备好了几组固定数据。选择 pressure 场景,就能看到三台节点、组件上报和任务运行记录。
小周看着控制台说:
"现在是我们自己运行 Java 文件。以后问助手'青禾平台有哪些节点',它怎么知道该调用哪段代码?"
小林把问题拆成了三步:
"先告诉客户端这里有什么工具,再说明每个工具需要什么参数,最后把查询结果返回去。"
这就是本篇要跑通的过程。
我们的第一个工具叫 ops_list_nodes。它只做一件事:根据集群标识,返回节点清单,以及每台节点上期望存在的组件最近报了什么状态。
这个"最近"很重要。查到一条 RUNNING,不等于已经证明组件此刻还在运行;还要看这条记录是什么时候上报的。

图 1:先把查询闭环跑通。助手拿到的每一个判断依据,都应该能在返回结果里找到。
本篇服务不连接模型,也不需要模型 Key。先用一个普通客户端把工具调通,再让助手使用它,问题会更容易定位。
2. 先约定这个工具接收什么、返回什么
小周最初想给工具一个参数:question,让模型把整句话传进来。
小林改成了三个明确的参数:
| 参数 | 示例 | 本篇约束 |
|---|---|---|
clusterId |
qinghe-demo |
必填,只接受教学清单内的集群 |
scene |
pressure |
必填,用于选择一组独立教学样本 |
limit |
50 |
必填,取值为 1~50 的整数 |
scene 是为了复现实验而增加的选择器。它让我们不用修改源码,就能查看正常、过期或缺失上报;真实系统的数据查询通常不会有这样一个"选择故障"的参数。
这次先使用 pressure。它沿用第二篇的组件快照:
| 节点 | 期望组件 | 最近上报 | 上报时间 |
|---|---|---|---|
demo-worker-a |
demo-executor |
RUNNING | 09:09 |
demo-worker-b |
demo-executor |
RUNNING | 09:09 |
demo-worker-c |
demo-executor |
RUNNING | 08:20 |
所有时间都属于 2026-09-16,偏移为 +08:00。实验观察时刻仍然固定在 09:10。
返回结果保留三层信息:查询范围、节点清单、组件上报。
尤其要保留 total、returned 和 truncated。假如用户只要返回一台节点,结果里的那一台不能被助手说成"整个集群只有这一台"。
本篇只有三台节点,所以用简单的返回上限控制结果量。以后资产规模变大,应在数据源侧过滤、排序和分页,再返回稳定的翻页信息。这里不把内存列表写成生产查询方案。
3. 把本地实验装进一个小服务
四篇配套代码放在同一个 lab-03-06 工程中,用启动配置控制工具逐步出现:
| 启动配置 | 可用工具 |
|---|---|
ch03 |
查询节点 |
ch04 |
查询节点 + 检查组件上报 |
ch05 |
再增加 CPU 趋势 |
ch06 |
再增加等待阶段查询 |
默认配置是 ch03。本篇只需要认识下面几个文件:
text
lab-03-06/
├── pom.xml
├── mcp_call.py
└── src/main/
├── resources/application.yml
└── java/example/qinghe/
├── OpsApplication.java
├── DemoData.java
├── NodeService.java
└── NodeTools.java
DemoData 准备虚构样本,NodeService 负责查询与校验,NodeTools 把方法登记成 MCP 工具。
Maven 配置如下。这里只引入 MCP 服务端和测试依赖:
xml
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.1.0</version>
<relativePath/>
</parent>
<groupId>example.qinghe</groupId>
<artifactId>qinghe-ops-lab</artifactId>
<version>1.0.0</version>
<properties>
<java.version>17</java.version>
<spring-ai.version>2.0.1</spring-ai.version>
</properties>
<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-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build><plugins><plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin></plugins></build>
</project>
服务配置为本机监听的无状态 Streamable HTTP:
yaml
server:
address: 127.0.0.1
port: 8082
spring:
application:
name: qinghe-ops-lab
profiles:
default: ch03
ai:
mcp:
server:
name: qinghe-teaching-tools
version: 1.0.0
type: SYNC
protocol: STATELESS
stateless:
mcp-endpoint: /mcp
STATELESS 指服务不为每个客户端保留会话状态;SYNC 指这里使用同步工具处理方法。它们解决的是不同问题。该模式及对应 WebMVC Starter 见 Spring AI 无状态 MCP 服务端文档。
启动类没有特殊逻辑:
java
package example.qinghe;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
/** 第 03---06 篇独立教学服务,只读取内存样本。 */
@SpringBootApplication
public class OpsApplication {
public static void main(String[] args) {
SpringApplication.run(OpsApplication.class, args);
}
}
这里监听 127.0.0.1,用于本地实验。集群白名单只是教学参数检查,不是用户身份或权限体系。
4. 先写普通查询,再注册成工具
小林先写 NodeService,没有急着加 MCP 注解。
"如果普通 Java 方法查出来就不对,接上模型也不会自动变对。"
这个服务从期望资产清单出发,再关联上报。某台节点没上报时,节点和期望组件仍然保留,缺的是状态与时间。
下面是完整的节点查询类:
java
package example.qinghe;
import io.swagger.v3.oas.annotations.media.Schema;
import java.time.OffsetDateTime;
import java.util.List;
import org.springframework.stereotype.Service;
/** 按资产清单返回节点;上报缺失时仍保留期望组件。 */
@Service
public class NodeService {
public record ComponentView(String component, @Schema(nullable=true) String reportedState,
@Schema(nullable=true) OffsetDateTime reportedAt, String freshness) {}
public record NodeView(String nodeId, List<ComponentView> components) {}
public record NodeResult(String clusterId, String scene, OffsetDateTime observedAt,
int total, int returned, boolean truncated, List<NodeView> items) {}
public NodeResult list(String clusterId, String scene, Integer limit) {
DemoData.validate(clusterId, scene);
if (limit == null || limit < 1 || limit > 50) {
throw new IllegalArgumentException("limit 必须为 1 到 50 的整数");
}
var reports = DemoData.reports(scene);
List<NodeView> items = DemoData.NODES.stream().limit(limit).map(node -> {
var report = reports.stream().filter(r -> r.nodeId().equals(node))
.findFirst().orElse(null);
var component = new ComponentView("demo-executor",
report == null ? null : report.reportedState(),
report == null ? null : report.reportedAt(),
DemoData.freshness(report == null ? null : report.reportedAt()));
return new NodeView(node, List.of(component));
}).toList();
return new NodeResult(clusterId, scene, DemoData.NOW, DemoData.NODES.size(),
items.size(), items.size() < DemoData.NODES.size(), items);
}
}
这里的 @Schema(nullable=true) 值得停一下。
Java 的引用字段可以为 null,但对外的 JSON Schema 也必须允许这个值。本例给可能缺失的上报状态、上报时间加上可空声明;否则启用输出结构校验后,缺失样本可能被当成不符合约定的工具返回。
这不是让助手猜空值是什么意思。freshness=MISSING 已经明确告诉它:本次没有对应的上报记录。
服务入口调用 DemoData.validate,依次检查标识格式、教学清单和场景枚举。清单与样本在 DemoData 中独立定义,完整文件如下:
java
package example.qinghe;
import java.time.Duration;
import java.time.OffsetDateTime;
import java.util.List;
import java.util.Set;
/** 虚构的资产清单和组件上报;没有外部数据源。 */
public final class DemoData {
private DemoData() {}
public static final OffsetDateTime NOW = at("09:10");
public static final List<String> NODES = List.of(
"demo-worker-a", "demo-worker-b", "demo-worker-c");
public static final Set<String> SCENES = Set.of(
"normal", "pressure", "mixed", "missing", "future", "unrecognized");
public record ComponentReport(String nodeId, String component,
String reportedState, OffsetDateTime reportedAt) {}
public static OffsetDateTime at(String time) {
return OffsetDateTime.parse("2026-09-16T" + time + ":00+08:00");
}
/** 先校验标识格式,再限制到公开教学清单;这不是生产权限校验。 */
public static void validate(String clusterId, String scene) {
if (clusterId == null || !clusterId.matches("[a-z][a-z0-9-]{0,39}")) {
throw new IllegalArgumentException("clusterId 格式错误,使用 qinghe-demo");
}
if (!"qinghe-demo".equals(clusterId)) {
throw new IllegalArgumentException("教学集群不存在:使用 qinghe-demo");
}
if (scene == null || !SCENES.contains(scene)) {
throw new IllegalArgumentException("scene 仅支持 normal/pressure/mixed/missing/future/unrecognized");
}
}
/** 固定实验时钟;恰好 5 分钟仍算新鲜,未来时间不能参与当前判断。 */
public static String freshness(OffsetDateTime reportedAt) {
if (reportedAt == null) return "MISSING";
Duration age = Duration.between(reportedAt, NOW);
if (age.isNegative()) return "INVALID_TIME";
return age.compareTo(Duration.ofMinutes(5)) > 0 ? "STALE" : "FRESH";
}
public static List<ComponentReport> reports(String scene) {
return NODES.stream().filter(node ->
!(scene.equals("missing") && node.endsWith("c")))
.map(node -> {
String state = scene.equals("mixed") && node.endsWith("b")
? "STOPPED" : "RUNNING";
if (scene.equals("unrecognized") && node.endsWith("b")) state = "STARTING";
OffsetDateTime time = at("09:09");
if ((scene.equals("pressure") || scene.equals("mixed")) && node.endsWith("c")) {
time = at("08:20");
}
if (scene.equals("future") && node.endsWith("c")) time = at("09:11");
return new ComponentReport(node, "demo-executor", state, time);
}).toList();
}
}
为了方便对照,normal 和 pressure 保留第二篇的节点组件设定;mixed、missing、future、unrecognized 是新增的独立样本,下一篇会用到。不同场景不是同一次运行依次经历的阶段。
代码看起来只是几次 if,但它已经回答了几个关键问题:不存在的集群怎么办、缺失上报怎么办、未来时间怎么办、记录过期怎么办。
这些边界先讲清楚,助手收到结果后才不需要自行补故事。
5. 用注解把方法登记成 MCP 工具
接着创建 NodeTools:
java
package example.qinghe;
import org.springframework.ai.mcp.annotation.McpTool;
import org.springframework.ai.mcp.annotation.McpToolParam;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Component;
/** 第 03 篇工具入口;业务校验与计算由服务完成。 */
@Component
@Profile({"ch03", "ch04", "ch05", "ch06"})
public class NodeTools {
private final NodeService service;
public NodeTools(NodeService service) { this.service = service; }
@McpTool(name="ops_list_nodes", description="查询教学集群节点及期望组件的最近上报;保留时间与时效,不推断整体健康。",
generateOutputSchema=true,
annotations=@McpTool.McpAnnotations(readOnlyHint=true, destructiveHint=false))
public NodeService.NodeResult list(
@McpToolParam(description="教学集群标识 qinghe-demo", required=true) String clusterId,
@McpToolParam(description="normal/pressure/mixed/missing/future/unrecognized,独立样本", required=true) String scene,
@McpToolParam(description="返回上限 1 到 50;truncated=true 表示未返回全部节点", required=true) Integer limit) {
return service.list(clusterId, scene, limit);
}
}
@Component 让这个对象进入 Spring 容器,@McpTool 声明工具名称和用途,@McpToolParam 说明参数。当前 Starter 会扫描带有 MCP 注解的 Bean,并注册相应工具;generateOutputSchema=true 开启输出结构生成。Spring AI MCP 服务端注解说明
这里直接使用这一条注册路径,不需要再为同一个方法手动添加另一套注册代码。
小周问:"参数描述里已经写了 limit 不能超过 50,服务里还要检查吗?"
当然要。
描述是给调用者看的,服务校验才负责守住边界。客户端可能漏传参数,模型可能给出超大数字,甚至有人直接构造请求绕过界面。
另外,readOnlyHint=true 是工具的只读提示。这个示例之所以只读,是因为实现确实只查询内存样本;提示本身不会替你建立访问控制。
6. 启动服务,真的调用一次
先确认 java -version 和 mvn -version 都使用 JDK 17 或兼容版本。在 lab-03-06 目录执行:
bash
mvn test package
java -jar target/qinghe-ops-lab-1.0.0.jar --spring.profiles.active=ch03
保持这个终端运行,另开一个终端进入同一目录。
配套的 mcp_call.py 只使用 Python 标准库。它会先发送初始化请求,读取服务协商的协议版本,再完成初始化通知和后续调用,避免把某个协议版本写死在所有请求里。这个顺序依据 MCP 生命周期说明。
先发现工具:
bash
python3 mcp_call.py list
默认章节应只有一个工具:ops_list_nodes。在返回里可以看到 inputSchema 和 outputSchema。
再查节点:
bash
python3 mcp_call.py ops_list_nodes \
'{"clusterId":"qinghe-demo","scene":"pressure","limit":50}'
调用所表达的 JSON-RPC 消息是:
json
{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"ops_list_nodes","arguments":{"clusterId":"qinghe-demo","scene":"pressure","limit":50}}}
服务实际返回的 result.structuredContent 如下:
json
{
"clusterId": "qinghe-demo",
"scene": "pressure",
"observedAt": "2026-09-16T09:10:00+08:00",
"total": 3,
"returned": 3,
"truncated": false,
"items": [
{
"nodeId": "demo-worker-a",
"components": [
{
"component": "demo-executor",
"reportedState": "RUNNING",
"reportedAt": "2026-09-16T09:09:00+08:00",
"freshness": "FRESH"
}
]
},
{
"nodeId": "demo-worker-b",
"components": [
{
"component": "demo-executor",
"reportedState": "RUNNING",
"reportedAt": "2026-09-16T09:09:00+08:00",
"freshness": "FRESH"
}
]
},
{
"nodeId": "demo-worker-c",
"components": [
{
"component": "demo-executor",
"reportedState": "RUNNING",
"reportedAt": "2026-09-16T08:20:00+08:00",
"freshness": "STALE"
}
]
}
]
}
外层还包括 isError=false,以及携带等价 JSON 文本的 content。结构化结果与输出 Schema 的关系见 MCP 工具规范。

图 2:三条上报的状态字符串相同,但能否用来判断当前状态并不相同。
7. 让它出错一次,才知道边界有没有生效
小周看到三台节点都返回了,准备继续写下一项功能。
小林把 limit 改成了 0:
bash
python3 mcp_call.py ops_list_nodes \
'{"clusterId":"qinghe-demo","scene":"pressure","limit":0}'
这次应该失败,而不是悄悄返回空列表。
空列表容易被理解成"没有节点";参数错误应该明确告诉调用者需要修正请求。配套脚本遇到协议错误或 isError=true 时,会以非零退出码结束。
再把 limit 改为 1,正常结果应该同时满足:
text
returned = 1
total = 3
truncated = true
最后试一次 scene=missing。节点 C 仍在清单中,期望组件仍是 demo-executor,但 reportedState 和 reportedAt 为 null,freshness 为 MISSING。
这三次检查分别确认:输入错误不会伪装成没数据,部分结果不会伪装成全部结果,缺失上报不会让节点凭空消失。
本篇配套验证覆盖了真实 MCP 工具发现、正常调用、非法参数、截断和结构化输出。它验证的是工具链路,没有调用真实大模型。
8. 助手拿到这些信息,应该怎样回答
小周的问题是:"青禾平台有哪些节点,分别运行什么组件?"
依据这组教学返回,可以组织成下面的回答。这是依据数据编写的预期回答,不是真实模型实测记录。
教学集群
qinghe-demo的清单中共有 3 台节点,本次已全部返回。A、B、C 三台节点都配置了教学执行器
demo-executor。A 和 B 最近一次上报为 09:09,状态均为 RUNNING;按本例 5 分钟时效规则,记录仍新鲜。C 最近一次记录是 08:20 的 RUNNING,距观察时刻 09:10 已过 50 分钟,记录过期,不能据此确认它当前仍在运行。
"所以,工具不是帮我们把一句话说得更肯定?"小周问。
小林点点头:"它帮我们知道,哪句话能说到哪一步。"
下一篇,我们就接着处理小周一定会问的那句话:"那这个集群,到底健不健康?"
上一篇:准备一个可复现的运维实验环境。