大数据智能运维实战(三):实现第一个运维 MCP 工具——查询节点与组件状态

系列: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 分钟,记录过期,不能据此确认它当前仍在运行。

"所以,工具不是帮我们把一句话说得更肯定?"小周问。

小林点点头:"它帮我们知道,哪句话能说到哪一步。"

下一篇,我们就接着处理小周一定会问的那句话:"那这个集群,到底健不健康?"

上一篇:准备一个可复现的运维实验环境。

相关推荐
fengkai45451 小时前
2、Kubernetes 介绍+安装+卸载
运维·容器·kubernetes
小蒜学长1 小时前
基于数据挖掘的京东商城商品推荐系统设计与实现(代码+数据库+LW)
java·数据库·spring boot·后端·推荐系统·个性化推荐
重生之小比特1 小时前
【Linux系统】13 基础IO(上)
linux·运维·服务器
HEJOO91 小时前
ElasticSearch 是什么?核心概念与应用场景详解
大数据·elasticsearch·搜索引擎
SEO_juper1 小时前
手写 llms.txt 验证器:让 AI 更愿意引用你的网站(GEO)
开发语言·人工智能·python·聚类·seo·独立站
lucas_AI1 小时前
AI 审查员把 PostgreSQL 19 拖延期了?committer 拆完 12 个撤回,故事没那么简单
人工智能
羑悻的小杀马特1 小时前
超越Bridge:全面掌握Docker网络隔离与通信的底层逻辑!
运维·网络·docker·容器
阡陌数智1 小时前
Anthropic Claude Fable 5.1 长周期 Agent 原生架构解析:工具调用链路稳定性与 API 聚合平台落地实战
人工智能·语言模型·自然语言处理·架构·推荐算法
IT_陈寒1 小时前
Vue开发实战:从入门到精通
前端·人工智能·后端