浏览代码

feat:fwh-agent-mcp新增MQTT报文量统计、自动升级配置、站点分析结果只读工具

- MQTT 报文量(iotcore-stat ES,UTC):mqtt_traffic_trend、mqtt_traffic_sites、mqtt_traffic_site_cmd、mqtt_message_search
- 自动升级:auto_update_catalog、auto_update_config_get(升级步骤附升级包标题)
- 站点分析:site_analysis_list、site_analysis_timeline、site_analysis_export(CSV)
- fwh-agent-mcp 初始化 ES 客户端,配置示例增加 FWH_ES_*

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Jerry Liang 4 天之前
父节点
当前提交
776f713a97

+ 7 - 1
deploy/agent-assistant/skills/fwh-assistant/SKILL.md

@@ -21,9 +21,12 @@ description: FranklinWH(FWH)储能业务数据助手。只要问题涉及 Fr
 | 站点 | `site_search`(关键字 + 国家、州省、在线、激活、运行状态、联网方式、版本、硬件、激活时间筛选;统计数量用 `size=1` 看 `total`)、`site_get`(单站点概况:版本、网络、工作模式、当前告警数)、`site_power_flow`(单日功率流)、`site_power_flow_export`(多日导出 CSV)、`site_info_sync_status`(站点信息表同步状态与最近同步时间) |
 | 告警 | `alarm_current`(当前告警)、`alarm_events`(时间范围内告警/事件)、`alarm_snapshot`(某日快照)、`alarm_stats`(全量统计)、`alarm_sites_by_code`(触发某告警码的站点) |
 | 停机 | `shutdown_stats`(某日汇总,不传日期取最新)、`shutdown_sites`(某日停机站点) |
-| 日志 | `mqtt_log_search`、`ems_log_records`、`update_log_system`(传记录 ID 看详情)、`update_log_ahub` |
+| 日志 | `mqtt_log_search`(单站点报文内容,站点当地时间)、`ems_log_records`、`update_log_system`(传记录 ID 看详情)、`update_log_ahub` |
+| MQTT 报文量(UTC) | `mqtt_traffic_trend`(月总量与每日量)、`mqtt_traffic_sites`(某日站点报文量排行)、`mqtt_traffic_site_cmd`(站点某日命令类型分布)、`mqtt_message_search`(跨站点按命令类型与时间检索,多数无报文内容) |
 | 故障录波(ToolBox 直连设备) | `fault_record_devices`(站点可操作的设备)、`fault_record_device_info`(读取设备录波信息,类别 3 为录波总目录)、`fault_record_param_read`(读取设备时间、录波开关)、`fault_record_operations`(操作记录)、`fault_record_operation_get`(操作结果)、`fault_record_data`(录波故障信息与波形概要)、`fault_record_export`(录波 CSV) |
 | 故障录波(云平台) | `cloud_fault_record_list`、`cloud_fault_record_detail`、`cloud_fault_record_callable`、`cloud_fault_record_call_history` |
+| 自动升级 | `auto_update_catalog`(aGate 硬件类型、升级包、aPower 版本)、`auto_update_config_get`(某硬件类型的升级条件与步骤) |
+| 站点分析 | `site_analysis_list`(页面创建的分析任务)、`site_analysis_timeline`(时间线)、`site_analysis_export`(时间线与运行数据 CSV) |
 | 调度 | `tou_template_list`、`tou_template_get`(`env=prod/test`,详情含调度模式识别) |
 | SIM | `sim_get`(自动判断平台)、`sim_search`、`sim_usage_monthly` |
 | 其他 | `ems_param_lookup`(PID/VID 宏名与描述,编号为十进制整数)、`plane_issue_search`、`plane_issue_get`、`sn_parse` |
@@ -39,6 +42,9 @@ description: FranklinWH(FWH)储能业务数据助手。只要问题涉及 Fr
 - **升级问题**:`update_log_system` 找到记录 → 传记录 ID 看详情;aHub 用 `update_log_ahub`。
 - **TOU 模版**:`tou_template_list` → `tou_template_get`;字段与调度模式见 references/tou-template.md。对比生产服与测试服时分别用 `env=prod`、`env=test` 取详情再逐策略、逐时段比较。
 - **故障录波**:`fault_record_operations` 先看是否已有召唤好的录波(`hasFile=true`)→ 有则 `fault_record_data` 看故障时间、触发源与波形概要,需要逐点分析时 `fault_record_export` 后用 Python 解压分析、作图。没有时:`fault_record_devices` 确认设备 → `fault_record_device_info`(`codes=[3]`)看设备上的录波记录序号 → 召唤用写工具 `fault_record_fetch`(见 references/write-operations.md)。读取设备信息、参数会实际向设备发送 MQTT 指令,设备离线时会超时。云平台记录的录波用 `cloud_fault_record_*`。
+- **报文量异常**:`mqtt_traffic_trend` 看哪天异常 → `mqtt_traffic_sites` 找报文最多的站点 → `mqtt_traffic_site_cmd` 看是哪类命令 → 需要报文内容用 `mqtt_log_search`。注意报文量统计按 UTC 日。
+- **自动升级**:`auto_update_catalog` → `auto_update_config_get`(站点的 aGate 硬件类型可从 `site_get` 获得),结合站点当前 EMS 版本、aPower 硬件判断会走标准还是特殊升级、按什么步骤升级。
+- **站点分析**:`site_analysis_list`(按 SN)→ 已完成的用 `site_analysis_timeline` 看上下线、模式切换、告警;运行数据多,用 `site_analysis_export` 后用 Python 分析。
 - **SIM 卡**:`sim_get` / `sim_search`;用量单位以工具说明为准,月度明细用 `sim_usage_monthly`。
 - **站点信息是否最新**:`site_search` 的数据来自定期同步的站点信息表。用户质疑数据过旧、找不到新激活的站点时,先用 `site_info_sync_status` 查看最近一次同步时间与状态,在回答中说明数据截至何时;需要重新同步见 references/write-operations.md。
 

+ 9 - 1
deploy/config.agent-mcp.env.example

@@ -6,7 +6,7 @@
 #           与主服务的 config.env、旧 MCP 的 config.mcp.env 互不共用;环境变量可覆盖文件内配置。
 #           文件不存在时服务拒绝启动。
 #
-# 说明:只包含 fwh-agent-mcp 用到的配置子集(数据库 / 缓存、MinIO 只读、FWH 生产与测试 API、Plane Web)。
+# 说明:只包含 fwh-agent-mcp 用到的配置子集(数据库 / 缓存、MinIO 只读、FWH 生产与测试 API、FWH ES、Plane Web)。
 #       设计见 docs/ai-assistant-agentserver/agent-mcp.md 第 6 节。
 # =============================================================================
 
@@ -54,6 +54,14 @@ FWH_TEST_OSS="test.franklinwh.com"
 FWH_TEST_USER="SuperAdmin"
 FWH_TEST_PASSWORD="CHANGE_ME"
 
+# ---------- FWH ES(MQTT 报文统计,iotcore-stat)----------
+# mqtt_traffic_*、mqtt_message_search 使用,与主服务 config.env 相同
+FWH_ES_HOST="CHANGE_ME"
+FWH_ES_USER="CHANGE_ME"
+FWH_ES_PASSWORD="CHANGE_ME"
+FWH_ES_INDEX_PREFIX="iotcore-stat"
+FWH_ES_INSECURE_SKIP_VERIFY=true
+
 # ---------- Plane Web ----------
 # plane_issue_search / plane_issue_get,以及写工具 feedback_create 使用
 PLANE_WEB_HOST="http://localhost:8600"

+ 6 - 1
docs/ai-assistant-agentserver/agent-mcp.md

@@ -168,7 +168,7 @@ schema 由 mcp-go 依赖的 `google/jsonschema-go` 生成:`jsonschema` 标签
 
 ## 4. 工具清单(一期,只读)
 
-按领域组织,约 30 个。“来源”列是复用的现有数据访问函数,实现时以代码为准。
+按领域组织,只读服务 45 个、写服务 7 个(工具数量超过接入指南建议的 20 个,全部完成后再统一考虑分组拆分)。“来源”列是复用的现有数据访问函数,实现时以代码为准。
 
 | 领域 | 工具 | 说明 | 来源 |
 | --- | --- | --- | --- |
@@ -198,6 +198,11 @@ schema 由 mcp-go 依赖的 `google/jsonschema-go` 生成:`jsonschema` 标签
 | | `cloud_fault_record_detail` | 录波子记录 | FWH Pro API |
 | | `cloud_fault_record_callable` | 设备可召唤的录波列表 | FWH Pro API |
 | | `cloud_fault_record_call_history` | 录波召唤记录 | FWH Pro API |
+| MQTT 报文量 | `mqtt_traffic_trend` | 月总量与每日量(UTC) | iotcore-stat ES |
+| | `mqtt_traffic_sites` / `mqtt_traffic_site_cmd` | 某日站点报文量排行、站点命令类型分布 | 同上 |
+| | `mqtt_message_search` | 跨站点按命令类型、时间检索报文元数据 | 同上 |
+| 自动升级 | `auto_update_catalog` / `auto_update_config_get` | 升级目录;某 aGate 硬件类型的升级条件与步骤(附升级包标题) | 自动升级配置集合 |
+| 站点分析 | `site_analysis_list` / `site_analysis_timeline` / `site_analysis_export` | 页面创建的分析任务、时间线、结果 CSV(只读) | 站点分析集合 |
 | 调度 | `tou_template_list` | TOU 模版列表,`env=prod/test` | 调度配置服务 |
 | | `tou_template_get` | TOU 模版详情:按季节 × 日类型整理时段,附调度模式识别结果 | 同上 + 调度配置文档中的解析规则 |
 | SIM | `sim_get` | 按 ICCID 查询,自动判断平台(SmartHub / Acceleronix) | SIM 卡管理服务 |

+ 3 - 0
service/agentmcp/agentmcp_test.go

@@ -84,6 +84,9 @@ func TestReadOnlyToolList(t *testing.T) {
 		"alarm_current", "alarm_events", "alarm_snapshot", "alarm_stats", "alarm_sites_by_code",
 		"shutdown_stats", "shutdown_sites",
 		"mqtt_log_search", "ems_log_records", "update_log_system", "update_log_ahub",
+		"mqtt_traffic_trend", "mqtt_traffic_sites", "mqtt_traffic_site_cmd", "mqtt_message_search",
+		"auto_update_catalog", "auto_update_config_get",
+		"site_analysis_list", "site_analysis_timeline", "site_analysis_export",
 		"fault_record_devices", "fault_record_device_info", "fault_record_param_read", "fault_record_operations", "fault_record_operation_get",
 		"fault_record_data", "fault_record_export",
 		"cloud_fault_record_list", "cloud_fault_record_detail", "cloud_fault_record_callable", "cloud_fault_record_call_history",

+ 3 - 1
service/agentmcp/bootstrap.go

@@ -97,7 +97,7 @@ func InitLogger(path string) *zap.Logger {
 	return l
 }
 
-// InitInfra 从 configFile 加载配置并初始化工具用到的基础设施:数据库、Redis、MinIO、FWH 生产/测试 API、Plane、任务队列入队客户端。
+// InitInfra 从 configFile 加载配置并初始化工具用到的基础设施:数据库、Redis、MinIO、FWH 生产/测试 API、FWH ES、Plane、任务队列入队客户端。
 func InitInfra(configFile string) error {
 	cfg, err := config.LoadConfig(configFile)
 	if err != nil {
@@ -114,6 +114,8 @@ func InitInfra(configFile string) error {
 	}
 	repositories.FwhHttpInit(&cfg.FwhApi)
 	repositories.FwhTestHttpInit(&cfg.FwhTestApi)
+	// MQTT 报文统计(iotcore-stat Elasticsearch):mqtt_traffic_*、mqtt_message_search
+	repositories.EsHttpInit(&cfg.FwhEs)
 	// 必须用 InitPlane(含 Feedback 专用实例 repositories.Plane),否则 feedback_create 会空指针
 	repositories.InitPlane(&cfg.PlaneWebApi)
 	// 只入队、不启动 worker:site_info_refresh、故障录波操作投递的任务由主服务执行(含 MQTT 收发)

+ 111 - 0
service/agentmcp/tools/autoupdate.go

@@ -0,0 +1,111 @@
+package tools
+
+// 自动升级配置(只读,与 ToolBox“自动升级”页面同源):按 aGate 硬件类型配置可自动升级的 EMS 版本、aPower 硬件与升级步骤(升级包)。
+
+import (
+	"context"
+	"errors"
+	"strings"
+
+	"github.com/mark3labs/mcp-go/mcp"
+	"go.mongodb.org/mongo-driver/mongo"
+
+	"fwh-toolbox-server/agentmcp/kit"
+	"fwh-toolbox-server/domain"
+	"fwh-toolbox-server/services"
+)
+
+// ---- auto_update_catalog ----
+
+var AutoUpdateCatalog = kit.Def[kit.NoArgs]{
+	Name:  "auto_update_catalog",
+	Title: "查询自动升级目录",
+	Description: "列出自动升级配置的目录:aGateHardware(已配置的 aGate 硬件类型,作为 auto_update_config_get 的参数)、" +
+		"packages(升级包文件名,按 system 系统包、aGate、subDevice 子设备包、others 分类)、aPowerVersions(aPower 硬件版本号与名称)。",
+	ReadOnly: true,
+	Handler: func(ctx context.Context, _ kit.NoArgs) (*mcp.CallToolResult, error) {
+		opt, err := services.GetAutoUpdateConfigOption()
+		if err != nil {
+			return kit.Upstream("ToolBox 自动升级配置", err), nil
+		}
+		ap, err := services.GetAutoUpdateAPowerVersion()
+		if err != nil {
+			return kit.Upstream("ToolBox 自动升级配置", err), nil
+		}
+		return kit.Object(map[string]any{"aGateHardware": opt.AGateVersions, "packages": opt.Packages, "aPowerVersions": ap}), nil
+	},
+}
+
+// ---- auto_update_config_get ----
+
+type autoUpdateConfigArgs struct {
+	AGate string `json:"aGate" jsonschema:"aGate 硬件类型(auto_update_catalog 的 aGateHardware 中的值)"`
+}
+
+var AutoUpdateConfigGet = kit.Def[autoUpdateConfigArgs]{
+	Name:  "auto_update_config_get",
+	Title: "查询 aGate 自动升级配置",
+	Description: "查询某个 aGate 硬件类型的自动升级配置:default(标准升级)与 special(备件换件、休眠等特殊场景)。" +
+		"每项含 emsVersions(满足条件的当前 EMS 版本)、aPowerVersions(满足条件的 aPower 硬件版本)、steps(按顺序执行的升级包:file、title、invVersion)。" +
+		"special 中 useDefaultUpdate=true 表示先执行特殊步骤再追加标准步骤,否则只执行特殊步骤。用于回答“某类站点会升级到什么版本”。",
+	ReadOnly: true,
+	Handler: func(ctx context.Context, a autoUpdateConfigArgs) (*mcp.CallToolResult, error) {
+		hw, err := kit.Required("aGate", a.AGate)
+		if err != nil {
+			return kit.Fail(err), nil
+		}
+		cfg, err := services.GetAutoUpdateConfig(hw)
+		if errors.Is(err, mongo.ErrNoDocuments) {
+			return kit.Failf("没有 aGate 硬件类型 %s 的自动升级配置,可用 auto_update_catalog 查看已配置的类型", hw), nil
+		}
+		if err != nil {
+			return kit.Upstream("ToolBox 自动升级配置", err), nil
+		}
+		packages := map[string]map[string]any{}
+		steps := func(files []string) []map[string]any {
+			out := make([]map[string]any, 0, len(files))
+			for _, f := range files {
+				p, ok := packages[f]
+				if !ok {
+					p = map[string]any{"file": f}
+					if info, err := services.GetAutoUpdatePackageInfo(f); err == nil && info != nil {
+						p["title"], p["invVersion"] = info.Title, info.InvVersion
+					} else {
+						p["note"] = "升级包信息缺失"
+					}
+					packages[f] = p
+				}
+				out = append(out, p)
+			}
+			return out
+		}
+		def := cfg.Config.DefaultType
+		special := make([]map[string]any, 0, len(cfg.Config.SpecialType))
+		for _, s := range cfg.Config.SpecialType {
+			special = append(special, map[string]any{
+				"title": s.Title, "emsVersions": versionList(s.EmsVersions), "aPowerVersions": s.APowerVersions,
+				"steps": steps(s.SpecialUpdateOrder), "useDefaultUpdate": s.UseDefaultUpdate,
+			})
+		}
+		return kit.Object(map[string]any{
+			"aGate": cfg.Id,
+			"default": map[string]any{
+				"emsVersions": versionList(def.EmsVersions), "aPowerVersions": def.APowerVersions, "steps": steps(def.UpdateOrder),
+			},
+			"special": special,
+		}), nil
+	},
+}
+
+// versionList EMS 版本条件:版本号(带备注时附在后面)
+func versionList(vs []domain.AutoUpdateSoftwareVersionInfo[string]) []string {
+	out := make([]string, 0, len(vs))
+	for _, v := range vs {
+		s := v.Version
+		if r := strings.TrimSpace(v.Remark); r != "" {
+			s += "(" + r + ")"
+		}
+		out = append(out, s)
+	}
+	return out
+}

+ 32 - 0
service/agentmcp/tools/misc_helpers_test.go

@@ -0,0 +1,32 @@
+package tools
+
+import (
+	"testing"
+
+	"fwh-toolbox-server/domain"
+)
+
+func TestEsDate(t *testing.T) {
+	if d, err := esDate("date", "2026-10-29"); err != nil || d != "2026.10.29" {
+		t.Errorf("esDate = %q %v", d, err)
+	}
+	if _, err := esDate("date", "2026.10.29"); err == nil {
+		t.Error("应拒绝非 yyyy-MM-dd 格式")
+	}
+}
+
+func TestTruncate(t *testing.T) {
+	if got := truncate("站点信息", 10); got != "站点信息" {
+		t.Errorf("未超长不应截断:%q", got)
+	}
+	if got := truncate("站点信息表", 2); got != "站点…(已截断)" {
+		t.Errorf("按字符截断:%q", got)
+	}
+}
+
+func TestVersionList(t *testing.T) {
+	got := versionList([]domain.AutoUpdateSoftwareVersionInfo[string]{{Version: "V10R11B42"}, {Version: "V10R11B60", Remark: " 北美 "}})
+	if len(got) != 2 || got[0] != "V10R11B42" || got[1] != "V10R11B60(北美)" {
+		t.Errorf("versionList = %v", got)
+	}
+}

+ 186 - 0
service/agentmcp/tools/mqttstats.go

@@ -0,0 +1,186 @@
+package tools
+
+// MQTT 报文统计(FWH iotcore-stat Elasticsearch,与 ToolBox“MQTT 报文管理 / MQTT 数据看板”同源)。
+// ES 按 UTC 日建索引:日期、时间参数一律按 UTC 解释。
+
+import (
+	"context"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"strconv"
+	"strings"
+	"time"
+
+	"github.com/mark3labs/mcp-go/mcp"
+
+	"fwh-toolbox-server/agentmcp/kit"
+	"fwh-toolbox-server/repositories"
+	"fwh-toolbox-server/services"
+)
+
+const mqttEsSource = "FWH MQTT 报文统计(ES)"
+
+// esDate 把 yyyy-MM-dd 转为 ES 索引日期 yyyy.MM.dd
+func esDate(name, v string) (string, error) {
+	t, err := kit.ParseDate(name, v, false)
+	if err != nil {
+		return "", err
+	}
+	return t.Format("2006.01.02"), nil
+}
+
+// ---- mqtt_traffic_trend ----
+
+type mqttTrendArgs struct {
+	Month string `json:"month" jsonschema:"月份,yyyy-MM(UTC)"`
+}
+
+var MqttTrafficTrend = kit.Def[mqttTrendArgs]{
+	Name:  "mqtt_traffic_trend",
+	Title: "查询 MQTT 报文月度趋势",
+	Description: "查询全部站点某月的 MQTT 报文总量与每日总量(UTC 日,整月补齐,缺失的天为 0)。" +
+		"用于观察报文量的整体趋势、定位异常的日期;某天哪些站点报文最多用 mqtt_traffic_sites。返回 month、total、days(date、total)。",
+	ReadOnly: true,
+	Handler: func(ctx context.Context, a mqttTrendArgs) (*mcp.CallToolResult, error) {
+		t, err := time.Parse("2006-01", strings.TrimSpace(a.Month))
+		if err != nil {
+			return kit.Failf("参数 month 格式错误,需要 yyyy-MM:%s", a.Month), nil
+		}
+		total, err := services.GetMqttMonthlyTotal(t.Year(), int(t.Month()))
+		if err != nil {
+			return kit.Upstream(mqttEsSource, err), nil
+		}
+		days, err := services.GetMqttMonthlyDailyTrend(t.Year(), int(t.Month()))
+		if err != nil {
+			return kit.Upstream(mqttEsSource, err), nil
+		}
+		return kit.Object(map[string]any{"month": t.Format("2006-01"), "total": total.Total, "days": days}), nil
+	},
+}
+
+// ---- mqtt_traffic_sites ----
+
+type mqttSitesArgs struct {
+	Date string `json:"date" jsonschema:"日期,yyyy-MM-dd(UTC)"`
+	Page int    `json:"page,omitempty" jsonschema:"页码,默认 1"`
+	Size int    `json:"size,omitempty" jsonschema:"每页条数,1~100,默认 20"`
+}
+
+var MqttTrafficSites = kit.Def[mqttSitesArgs]{
+	Name:  "mqtt_traffic_sites",
+	Title: "查询站点 MQTT 报文量排行",
+	Description: "查询某一天(UTC)各站点的 MQTT 报文量,按报文量从多到少分页返回。用于找出报文量异常偏高的站点。" +
+		"返回 siteCount(当天有报文的站点数)与 items(sn、total)。某站点各命令类型的分布用 mqtt_traffic_site_cmd。",
+	ReadOnly: true,
+	Handler: func(ctx context.Context, a mqttSitesArgs) (*mcp.CallToolResult, error) {
+		date, err := esDate("date", a.Date)
+		if err != nil {
+			return kit.Fail(err), nil
+		}
+		page, size := kit.Page(a.Page), kit.Limit(a.Size, 20, 100)
+		res, err := services.GetMqttSiteDailyTotals(date, page, size)
+		if err != nil {
+			return kit.Upstream(mqttEsSource, err), nil
+		}
+		items := make([]map[string]any, 0, len(res.Items))
+		for _, it := range res.Items {
+			items = append(items, map[string]any{"sn": it.EquipNo, "total": it.Total})
+		}
+		return kit.List(items, int(res.SiteCount), size, fmt.Sprintf("第 %d 页,按报文量降序,可用 page 翻页", page)), nil
+	},
+}
+
+// ---- mqtt_traffic_site_cmd ----
+
+type mqttSiteCmdArgs struct {
+	SN   string `json:"sn" jsonschema:"站点 SN(aGate 序列号)"`
+	Date string `json:"date" jsonschema:"日期,yyyy-MM-dd(UTC)"`
+}
+
+var MqttTrafficSiteCmd = kit.Def[mqttSiteCmdArgs]{
+	Name:  "mqtt_traffic_site_cmd",
+	Title: "查询站点 MQTT 命令类型分布",
+	Description: "查询单个站点某一天(UTC)各 MQTT 命令类型(cmdType)的报文量,按报文量降序。用于分析某站点报文量异常的来源。" +
+		"返回 cmdTypeCount 与 items(cmdType、total)。具体报文用 mqtt_message_search 或 mqtt_log_search。",
+	ReadOnly: true,
+	Handler: func(ctx context.Context, a mqttSiteCmdArgs) (*mcp.CallToolResult, error) {
+		sn, err := kit.Required("sn", a.SN)
+		if err != nil {
+			return kit.Fail(err), nil
+		}
+		date, err := esDate("date", a.Date)
+		if err != nil {
+			return kit.Fail(err), nil
+		}
+		res, err := services.GetMqttSiteCmdTypeTotals(sn, date, 1, 200)
+		if err != nil {
+			return kit.Upstream(mqttEsSource, err), nil
+		}
+		return kit.List(res.Items, int(res.CmdTypeCount), 200, ""), nil
+	},
+}
+
+// ---- mqtt_message_search ----
+
+type mqttMessageSearchArgs struct {
+	SN      string `json:"sn,omitempty" jsonschema:"站点 SN;不填查全部站点(此时建议指定 cmdType 并缩短时间范围)"`
+	CmdType *int   `json:"cmdType,omitempty" jsonschema:"MQTT 命令类型,如 0、101、105、107、201、211"`
+	From    string `json:"from" jsonschema:"开始时间,yyyy-MM-dd HH:mm:ss(UTC)"`
+	To      string `json:"to" jsonschema:"结束时间,同上;与开始时间相差不超过 7 天"`
+	Limit   int    `json:"limit,omitempty" jsonschema:"最多返回条数,1~200,默认 50"`
+}
+
+var MqttMessageSearch = kit.Def[mqttMessageSearchArgs]{
+	Name:  "mqtt_message_search",
+	Title: "检索 MQTT 报文(ES)",
+	Description: "在 MQTT 报文统计库(ES)中按站点、命令类型、时间(UTC)检索逐条报文,按时间倒序。可以不指定站点,跨站点查找某类报文。" +
+		"返回 total(命中数,可能为下界)与 items:time(UTC)、sn、cmdType,有内容时附 event(超过 1500 字截断)。" +
+		"统计库主要保存报文的时间、站点与命令类型,多数报文没有 event 内容,适合统计与定位时间点;需要报文内容时用 mqtt_log_search(FWH 平台日志,时间为站点当地时间)。",
+	ReadOnly: true,
+	Handler: func(ctx context.Context, a mqttMessageSearchArgs) (*mcp.CallToolResult, error) {
+		from, to, err := kit.ParseRange(a.From, a.To, 7*24*time.Hour)
+		if err != nil {
+			return kit.Fail(err), nil
+		}
+		// ParseRange 按本地时区解析;ES 的时间是 UTC,按字面值换为 UTC
+		from = time.Date(from.Year(), from.Month(), from.Day(), from.Hour(), from.Minute(), from.Second(), 0, time.UTC)
+		to = time.Date(to.Year(), to.Month(), to.Day(), to.Hour(), to.Minute(), to.Second(), 0, time.UTC)
+		limit := kit.Limit(a.Limit, 50, 200)
+		var sn *string
+		if v := strings.TrimSpace(a.SN); v != "" {
+			sn = &v
+		}
+		var cmd *int64
+		if a.CmdType != nil {
+			v := int64(*a.CmdType)
+			cmd = &v
+		}
+		res, err := services.QueryMqttMessages(sn, cmd, &from, &to, 1, limit)
+		if errors.Is(err, repositories.ErrEsDeepPaging) {
+			return kit.Failf("命中的报文太多,请缩短时间范围或指定站点、命令类型。"), nil
+		}
+		if err != nil {
+			return kit.Upstream(mqttEsSource, err), nil
+		}
+		items := make([]map[string]any, 0, len(res.Items))
+		for _, m := range res.Items {
+			item := map[string]any{"time": m.Timestamp, "sn": m.EquipNo, "cmdType": strconv.FormatInt(m.CmdType, 10)}
+			if len(m.Event) > 0 {
+				if b, err := json.Marshal(m.Event); err == nil {
+					item["event"] = truncate(string(b), 1500)
+				}
+			}
+			items = append(items, item)
+		}
+		return kit.List(items, int(res.Total), limit, "超过 limit 时请缩短时间范围或增加条件"), nil
+	},
+}
+
+func truncate(s string, n int) string {
+	r := []rune(s)
+	if len(r) <= n {
+		return s
+	}
+	return string(r[:n]) + "…(已截断)"
+}

+ 6 - 0
service/agentmcp/tools/registry.go

@@ -14,11 +14,17 @@ func ReadOnly() []kit.Registrar {
 		ShutdownStats, ShutdownSites,
 		// 日志
 		MqttLogSearch, EmsLogRecords, UpdateLogSystem, UpdateLogAhub,
+		// MQTT 报文统计(ES)
+		MqttTrafficTrend, MqttTrafficSites, MqttTrafficSiteCmd, MqttMessageSearch,
 		// 故障录波(ToolBox 直连设备)
 		FaultRecordDevices, FaultRecordDeviceInfo, FaultRecordParamRead, FaultRecordOperations, FaultRecordOperationGet,
 		FaultRecordData, FaultRecordExport,
 		// 故障录波(云平台)
 		CloudFaultRecordList, CloudFaultRecordDetail, CloudFaultRecordCallable, CloudFaultRecordCallHistory,
+		// 自动升级配置
+		AutoUpdateCatalog, AutoUpdateConfigGet,
+		// 站点分析结果
+		SiteAnalysisList, SiteAnalysisTimeline, SiteAnalysisExport,
 		// 调度
 		TouTemplateList, TouTemplateGet,
 		// SIM

+ 143 - 0
service/agentmcp/tools/siteanalysis.go

@@ -0,0 +1,143 @@
+package tools
+
+// 站点分析结果(只读,与 ToolBox“站点分析管理”同源):分析任务由页面创建,这里只读取结果。
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"strings"
+
+	"github.com/mark3labs/mcp-go/mcp"
+	"go.mongodb.org/mongo-driver/bson/primitive"
+	"go.mongodb.org/mongo-driver/mongo"
+
+	"fwh-toolbox-server/agentmcp/kit"
+	"fwh-toolbox-server/domain"
+	"fwh-toolbox-server/dto"
+	"fwh-toolbox-server/services"
+)
+
+const siteAnalysisSource = "ToolBox 站点分析"
+
+// ---- site_analysis_list ----
+
+type siteAnalysisListArgs struct {
+	SN       string `json:"sn,omitempty" jsonschema:"站点 SN;不填查全部"`
+	Progress string `json:"progress,omitempty" jsonschema:"进度:pending、running、complete、error"`
+	Page     int    `json:"page,omitempty" jsonschema:"页码,默认 1"`
+	Size     int    `json:"size,omitempty" jsonschema:"每页条数,1~100,默认 20"`
+}
+
+var SiteAnalysisList = kit.Def[siteAnalysisListArgs]{
+	Name:  "site_analysis_list",
+	Title: "查询站点分析任务",
+	Description: "查询 ToolBox 中已创建的站点分析任务(页面创建,分析一段时间内站点的上下线、模式切换、告警、事件与运行数据)。" +
+		"返回 total 与 items:id(用于 site_analysis_timeline、site_analysis_export)、sn、description、timePeriod(分析时间段)、item(分析项)、progress、trigger、user。",
+	ReadOnly:   true,
+	Properties: map[string]map[string]any{"progress": {"enum": []string{"pending", "running", "complete", "error"}}},
+	Handler: func(ctx context.Context, a siteAnalysisListArgs) (*mcp.CallToolResult, error) {
+		page, size := kit.Page(a.Page), kit.Limit(a.Size, 20, 100)
+		var sn, progress *string
+		if v := strings.TrimSpace(a.SN); v != "" {
+			sn = &v
+		}
+		if a.Progress != "" {
+			progress = &a.Progress
+		}
+		res, err := services.GetSiteAnalysisManageList(uint(page), uint(size), sn, nil, progress, nil)
+		if err != nil {
+			return kit.Upstream(siteAnalysisSource, err), nil
+		}
+		return kit.List(res.Data, int(res.Total), size, "用 page 翻页,或用 sn 缩小范围"), nil
+	},
+}
+
+// ---- site_analysis_timeline ----
+
+type siteAnalysisTimelineArgs struct {
+	ID    string `json:"id" jsonschema:"分析任务 id(site_analysis_list 返回)"`
+	Type  string `json:"type,omitempty" jsonschema:"只看某类:offline(离线)、online(上线)、mode_change(模式切换)、alarm(告警)、event(事件);默认全部"`
+	Limit int    `json:"limit,omitempty" jsonschema:"最多返回条数,1~500,默认 200"`
+}
+
+var SiteAnalysisTimeline = kit.Def[siteAnalysisTimelineArgs]{
+	Name:  "site_analysis_timeline",
+	Title: "查看站点分析时间线",
+	Description: "查看一个已完成的站点分析任务的时间线:上下线、模式切换、告警、事件,按时间排序,以表格返回(time、type、info、data,data 超过 300 字截断)。" +
+		"时间线很长或需要运行数据(201 报文)时用 site_analysis_export 导出 CSV 分析。",
+	ReadOnly:   true,
+	Properties: map[string]map[string]any{"type": {"enum": []string{"offline", "online", "mode_change", "alarm", "event"}}},
+	Handler: func(ctx context.Context, a siteAnalysisTimelineArgs) (*mcp.CallToolResult, error) {
+		data, fail := siteAnalysisData(a.ID)
+		if fail != nil {
+			return fail, nil
+		}
+		limit := kit.Limit(a.Limit, 200, 500)
+		rows := [][]string{}
+		for _, it := range data.Data.Timeline {
+			if a.Type != "" && string(it.Type) != a.Type {
+				continue
+			}
+			rows = append(rows, []string{it.Time, string(it.Type), deref(it.Info), truncate(deref(it.Data), 300)})
+		}
+		if len(rows) == 0 {
+			return kit.Empty("时间线中没有符合条件的记录(任务进度:" + string(data.Progress) + ")。"), nil
+		}
+		return kit.Table([]string{"time", "type", "info", "data"}, rows, limit, "超过 limit 时请指定 type,或用 site_analysis_export 导出"), nil
+	},
+}
+
+// ---- site_analysis_export ----
+
+type siteAnalysisExportArgs struct {
+	ID string `json:"id" jsonschema:"分析任务 id(site_analysis_list 返回)"`
+}
+
+var SiteAnalysisExport = kit.Def[siteAnalysisExportArgs]{
+	Name:  "site_analysis_export",
+	Title: "导出站点分析结果",
+	Description: "把一个站点分析任务的全部结果导出为 CSV 文件保存到会话工作区:section=timeline 为时间线(type、info、data)," +
+		"section=runtime 为运行数据(data 为 201 报文 JSON)。请用 Python(pandas)读取分析,不要把全文读入上下文。",
+	ReadOnly: true,
+	Handler: func(ctx context.Context, a siteAnalysisExportArgs) (*mcp.CallToolResult, error) {
+		data, fail := siteAnalysisData(a.ID)
+		if fail != nil {
+			return fail, nil
+		}
+		rows := make([][]string, 0, len(data.Data.Timeline)+len(data.Data.Runtime))
+		for _, it := range data.Data.Timeline {
+			rows = append(rows, []string{"timeline", it.Time, string(it.Type), deref(it.Info), deref(it.Data)})
+		}
+		for _, it := range data.Data.Runtime {
+			rows = append(rows, []string{"runtime", it.Time, "", "", it.Data_201})
+		}
+		summary := fmt.Sprintf("站点 %s:时间线 %d 条,运行数据 %d 条。", data.Sn, len(data.Data.Timeline), len(data.Data.Runtime))
+		return kit.Export("fwh://site-analysis/"+data.Sn+"_"+data.Id.Hex()+".csv", []string{"section", "time", "type", "info", "data"}, rows, summary), nil
+	},
+}
+
+func siteAnalysisData(idStr string) (*dto.SiteAnalysisDataInfo, *mcp.CallToolResult) {
+	id, err := primitive.ObjectIDFromHex(strings.TrimSpace(idStr))
+	if err != nil {
+		return nil, kit.Failf("参数 id 格式错误:%s", idStr)
+	}
+	data, err := services.GetSiteAnalysisData(id)
+	if errors.Is(err, mongo.ErrNoDocuments) || (err == nil && data == nil) {
+		return nil, kit.Failf("没有找到分析任务 %s,可以用 site_analysis_list 查看", idStr)
+	}
+	if err != nil {
+		return nil, kit.Upstream(siteAnalysisSource, err)
+	}
+	if data.Progress != domain.ProgressComplete {
+		return nil, kit.Failf("分析任务 %s 尚未完成(进度:%s),完成后再查看结果", idStr, data.Progress)
+	}
+	return data, nil
+}
+
+func deref(s *string) string {
+	if s == nil {
+		return ""
+	}
+	return *s
+}