Sfoglia il codice sorgente

fix:优化mcp工具的AI调用性能

Jerry Liang 2 mesi fa
parent
commit
a16796d9c9

+ 2 - 1
service/cmd/mcp/main.go

@@ -154,4 +154,5 @@ const serverInstructions = `FranklinWH 工具箱 MCP Server — 内部工具平
 5. 查询 MongoDB 时注意 collection 白名单限制
 6. MinIO 操作受 bucket 白名单限制
 7. 描述中标注 [写操作] 的工具由 Agent 权限管控,执行前会请求用户确认
-8. 大结果落盘:当工具返回的 JSON 中包含 "spilled": true 时,表示完整结果已写入本地文件,请使用文件读取工具按返回的 "localPath" 读取完整内容;"preview" 字段仅为前部片段,不代表全部数据。落盘文件位于 /tmp/fwh-toolbox-agent/ 目录下`
+8. 大结果落盘:当工具返回的 JSON 中包含 "spilled": true 时,表示完整结果已写入本地文件,请使用文件读取工具按返回的 "localPath" 读取完整内容;"preview" 字段仅为前部片段,不代表全部数据。落盘文件位于 /tmp/fwh-toolbox-agent/ 目录下。返回中若含 "summary" 字段(数据规模与字段名摘要),可据此判断是否真的需要读回完整文件,尽量节省 token。
+9. 大数据量导出:需要一次性拉取超过分页上限的大量数据时(如导出某集合的全部记录),优先使用 data_mongo_export 等 *_export 工具。它们流式将结果写入 NDJSON 文件(每行一个 JSON 对象,"format":"ndjson"),仅返回 "exported": true + "localPath" + "count" + "summary"(不返回数据正文),请使用文件读取工具按 localPath 逐行读取解析。为防误导出全表,无 filter 且数据量过大时会被拒绝,此时应补充 filter 或显式调高 max_docs`

+ 132 - 3
service/cmd/mcp/tools/data/mongo.go

@@ -46,6 +46,8 @@ func registerMongoTools(s *server.MCPServer) {
 				fmt.Sprintf("集合名,可选值: %v", mongoAllowedCollections),
 			)),
 			mcp.WithString("filter", mcp.Description("查询条件,JSON 格式的 MongoDB filter(如 {\"sn\": \"xxx\"}),不传则查全部")),
+			mcp.WithArray("fields", mcp.WithStringItems(),
+				mcp.Description("字段投影:要返回的字段名数组(如 [\"sn\",\"status\"]),留空返回全部字段。用于大数据量场景下减少返回体积、节省 token。")),
 			mcp.WithNumber("limit", mcp.Description("返回数量限制,默认10,最大100")),
 			mcp.WithNumber("skip", mcp.Description("跳过数量(分页偏移),默认0")),
 			mcp.WithString("sort", mcp.Description("排序条件,JSON 格式(如 {\"createdAt\": -1})")),
@@ -63,8 +65,30 @@ func registerMongoTools(s *server.MCPServer) {
 		),
 		handleMongoCount,
 	)
+
+	s.AddTool(
+		mcp.NewTool("data_mongo_export",
+			mcp.WithDescription("全量导出 MongoDB 集合数据到本地文件(只读)。用于大数据量导出场景:不受 find 的 100 条上限限制,游标流式拉取符合条件的全部文档,边脱敏边写入 NDJSON 文件(每行一个 JSON 对象),内存占用为常数级。仅返回 localPath、count 与结构化摘要(不返回数据正文,避免占用上下文)。为防止误导出全表,无 filter 时若总数超过上限会被拒绝。"),
+			mcp.WithString("collection", mcp.Required(), mcp.Description(
+				fmt.Sprintf("集合名,可选值: %v", mongoAllowedCollections),
+			)),
+			mcp.WithString("filter", mcp.Description("查询条件,JSON 格式的 MongoDB filter,不传则导出全部(受上限保护)")),
+			mcp.WithArray("fields", mcp.WithStringItems(),
+				mcp.Description("字段投影:只导出这些字段(如 [\"sn\",\"status\"]),留空导出全部字段。")),
+			mcp.WithString("sort", mcp.Description("排序条件,JSON 格式(如 {\"createdAt\": -1})")),
+			mcp.WithNumber("max_docs", mcp.Description(fmt.Sprintf("最大导出条数上限,默认 %d,硬上限 %d。超过则拒绝,请缩小 filter。", mongoExportDefaultMax, mongoExportHardMax))),
+		),
+		handleMongoExport,
+	)
 }
 
+// mongoExportDefaultMax 全量导出默认上限(防误导出全表的软保护,可由 max_docs 覆盖)
+const mongoExportDefaultMax = 50000
+
+// mongoExportHardMax 全量导出硬上限。流式导出内存为常数级,此上限仅用于防止
+// 误导出超大规模数据,而非内存保护,故设置较高。
+const mongoExportHardMax = 5000000
+
 func handleMongoFind(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
 	collection, err := req.RequireString("collection")
 	if err != nil {
@@ -113,6 +137,15 @@ func handleMongoFind(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToo
 		findOpts.SetSort(sort)
 	}
 
+	// fields 字段投影:留空返回全部字段;指定则只返回这些字段(源头减量,节省 token)
+	if fields, ok := helper.GetStringSlice(req, "fields"); ok && len(fields) > 0 {
+		projection := bson.M{}
+		for _, f := range fields {
+			projection[f] = 1
+		}
+		findOpts.SetProjection(projection)
+	}
+
 	cursor, err := coll.Find(ctx, filter, findOpts)
 	if err != nil {
 		return helper.ServiceError("MongoDB查询", err), nil
@@ -124,11 +157,24 @@ func handleMongoFind(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToo
 		return helper.ServiceError("读取MongoDB结果", err), nil
 	}
 
-	if len(results) == 0 {
-		return helper.EmptyResult("查询结果为空"), nil
+	// total:符合 filter 的文档总数(不受 limit/skip 影响),便于大数据量遍历时判断进度
+	total, err := coll.CountDocuments(ctx, filter)
+	if err != nil {
+		return helper.ServiceError("统计MongoDB总数", err), nil
 	}
 
-	return helper.JSONResultOrFileAuto(results), nil
+	returned := int64(len(results))
+	hasMore := skip+returned < total
+
+	// 结构化返回:附带 total/returned/skip/limit/hasMore,便于 AI 判断是否翻完、如何翻页
+	return helper.JSONResultOrFileAuto(map[string]any{
+		"total":    total,
+		"returned": returned,
+		"skip":     skip,
+		"limit":    limit,
+		"hasMore":  hasMore,
+		"items":    results,
+	}), nil
 }
 
 func handleMongoCount(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
@@ -159,6 +205,89 @@ func handleMongoCount(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallTo
 	}), nil
 }
 
+func handleMongoExport(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
+	collection, err := req.RequireString("collection")
+	if err != nil {
+		return helper.ParamError("collection"), nil
+	}
+	if !isAllowedCollection(collection) {
+		return helper.ParamInvalidError("collection", fmt.Sprintf("不允许查询此集合,可选: %v", mongoAllowedCollections)), nil
+	}
+
+	// 解析 filter
+	filterStr := req.GetString("filter", "{}")
+	var filter bson.M
+	if err := json.Unmarshal([]byte(filterStr), &filter); err != nil {
+		return helper.ParamInvalidError("filter", fmt.Sprintf("JSON 解析失败: %v", err)), nil
+	}
+	hasFilter := len(filter) > 0
+
+	// max_docs 上限
+	maxDocs := int64(req.GetFloat("max_docs", float64(mongoExportDefaultMax)))
+	if maxDocs < 1 {
+		maxDocs = mongoExportDefaultMax
+	}
+	if maxDocs > mongoExportHardMax {
+		maxDocs = mongoExportHardMax
+	}
+
+	coll := database.GetCollection(collection)
+
+	// 先统计总量,做防误导出保护
+	total, err := coll.CountDocuments(ctx, filter)
+	if err != nil {
+		return helper.ServiceError("统计导出总数", err), nil
+	}
+	if total == 0 {
+		return helper.EmptyResult("符合条件的文档为 0,无需导出"), nil
+	}
+	if total > maxDocs {
+		hint := "请缩小 filter 或调高 max_docs"
+		if !hasFilter {
+			hint = "未提供 filter 且数据量过大,请提供 filter 缩小范围,或显式调高 max_docs"
+		}
+		return helper.ParamInvalidError("max_docs", fmt.Sprintf(
+			"符合条件的文档数 %d 超过上限 %d,%s(硬上限 %d)", total, maxDocs, hint, mongoExportHardMax)), nil
+	}
+
+	// 组装查询选项
+	findOpts := options.Find()
+	if sortStr := req.GetString("sort", ""); sortStr != "" {
+		var sort bson.M
+		if err := json.Unmarshal([]byte(sortStr), &sort); err != nil {
+			return helper.ParamInvalidError("sort", fmt.Sprintf("JSON 解析失败: %v", err)), nil
+		}
+		findOpts.SetSort(sort)
+	}
+	if fields, ok := helper.GetStringSlice(req, "fields"); ok && len(fields) > 0 {
+		projection := bson.M{}
+		for _, f := range fields {
+			projection[f] = 1
+		}
+		findOpts.SetProjection(projection)
+	}
+
+	// 游标流式导出:逐条 decode → 脱敏 → 写 NDJSON,内存常数级
+	cursor, err := coll.Find(ctx, filter, findOpts)
+	if err != nil {
+		return helper.ServiceError("MongoDB导出查询", err), nil
+	}
+	defer cursor.Close(ctx)
+
+	res := helper.ExportStreamNDJSON(func() (any, bool, error) {
+		if !cursor.Next(ctx) {
+			// Next 返回 false:可能是正常结束,也可能是出错
+			return nil, false, cursor.Err()
+		}
+		var doc bson.M
+		if err := cursor.Decode(&doc); err != nil {
+			return nil, false, err
+		}
+		return doc, true, nil
+	}, "mongo_export_"+collection)
+	return res, nil
+}
+
 func isAllowedCollection(name string) bool {
 	for _, c := range mongoAllowedCollections {
 		if c == name {

+ 1 - 1
service/cmd/mcp/tools/data/plane_api_handlers.go

@@ -222,7 +222,7 @@ func handlePlaneGetIntakeIssueList(ctx context.Context, req mcp.CallToolRequest)
 	if err != nil {
 		return helper.ServiceError("查询Intake列表", err), nil
 	}
-	return helper.JSONResult(data), nil
+	return helper.JSONResultOrFileAuto(data), nil
 }
 
 func handlePlaneGetIntakeIssueDetail(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {

+ 30 - 9
service/cmd/mcp/tools/data/redis.go

@@ -18,6 +18,7 @@ func registerRedisTools(s *server.MCPServer) {
 			mcp.WithDescription("读取 Redis 中指定 key 的值(只读,仅支持 string 和 hash 类型)"),
 			mcp.WithString("key", mcp.Required(), mcp.Description("Redis key")),
 			mcp.WithString("type", mcp.Description("key 的数据类型:string(默认)、hash、list、set")),
+			mcp.WithNumber("limit", mcp.Description("list/set 类型返回的最大元素数,默认100,最大1000。string/hash 忽略此参数。")),
 		),
 		handleRedisGet,
 	)
@@ -39,6 +40,15 @@ func handleRedisGet(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallTool
 
 	keyType := req.GetString("type", "string")
 
+	// limit 仅对 list/set 生效,默认100,上限1000
+	limit := int(req.GetFloat("limit", 100))
+	if limit < 1 {
+		limit = 100
+	}
+	if limit > 1000 {
+		limit = 1000
+	}
+
 	switch keyType {
 	case "string":
 		val, err := redisx.Get(ctx, key)
@@ -66,7 +76,7 @@ func handleRedisGet(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallTool
 		}), nil
 
 	case "list":
-		val, err := redisx.LRange(ctx, key, 0, 99)
+		val, err := redisx.LRange(ctx, key, 0, int64(limit-1))
 		if err != nil {
 			return helper.ServiceError("读取Redis list", err), nil
 		}
@@ -74,10 +84,12 @@ func handleRedisGet(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallTool
 			return helper.EmptyResult(fmt.Sprintf("key 不存在或为空: %s", key)), nil
 		}
 		return helper.JSONResultOrFileAuto(map[string]any{
-			"key":   key,
-			"type":  "list",
-			"count": len(val),
-			"value": val,
+			"key":       key,
+			"type":      "list",
+			"count":     len(val),
+			"limit":     limit,
+			"truncated": len(val) == limit,
+			"value":     val,
 		}), nil
 
 	case "set":
@@ -88,11 +100,20 @@ func handleRedisGet(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallTool
 		if len(val) == 0 {
 			return helper.EmptyResult(fmt.Sprintf("key 不存在或为空: %s", key)), nil
 		}
+		total := len(val)
+		truncated := false
+		if total > limit {
+			val = val[:limit]
+			truncated = true
+		}
 		return helper.JSONResultOrFileAuto(map[string]any{
-			"key":   key,
-			"type":  "set",
-			"count": len(val),
-			"value": val,
+			"key":       key,
+			"type":      "set",
+			"total":     total,
+			"count":     len(val),
+			"limit":     limit,
+			"truncated": truncated,
+			"value":     val,
 		}), nil
 
 	default:

+ 86 - 0
service/cmd/mcp/tools/helper/helper.go

@@ -2,10 +2,40 @@ package helper
 
 import (
 	"fmt"
+	"reflect"
 
 	"github.com/mark3labs/mcp-go/mcp"
 )
 
+// GetStringSlice 从请求参数中读取字符串数组(结构化数组 schema,如 WithArray+WithStringItems)。
+//   - 参数缺失或为 nil:返回 (nil, false)
+//   - 参数为 []any:逐元素转字符串(仅接受 string 元素,遇到非字符串元素返回 ok=false)
+//   - 其余类型:返回 ok=false
+//
+// 注意:本函数不接受 JSON 字符串形式的入参,参数应通过结构化数组 schema 传入。
+func GetStringSlice(req mcp.CallToolRequest, key string) ([]string, bool) {
+	args := req.GetArguments()
+	raw, exists := args[key]
+	if !exists || raw == nil {
+		return nil, false
+	}
+
+	arr, ok := raw.([]any)
+	if !ok {
+		return nil, false
+	}
+
+	out := make([]string, 0, len(arr))
+	for _, item := range arr {
+		s, ok := item.(string)
+		if !ok {
+			return nil, false
+		}
+		out = append(out, s)
+	}
+	return out, true
+}
+
 // JSONResult 将任意数据序列化为 JSON 文本返回,序列化前完成 PII 脱敏(A+B)。
 // 脱敏失败时返回错误,拒绝输出(fail-closed)。
 func JSONResult(data any) *mcp.CallToolResult {
@@ -36,7 +66,63 @@ func ServiceError(action string, err error) *mcp.CallToolResult {
 	return mcp.NewToolResultError(fmt.Sprintf("%s失败: %v", action, err))
 }
 
+// BatchValidationError 批量校验错误:返回结构化的 valid/invalid 分离结果,
+// 便于 Agent 精确定位哪些输入非法并只修正这些项,而无需重猜整批。
+// reason 为对非法项的统一说明(如 "SN 格式非法")。
+func BatchValidationError(action string, valid []string, invalid []string, reason string) *mcp.CallToolResult {
+	return JSONResult(map[string]any{
+		"error":   true,
+		"action":  action,
+		"reason":  reason,
+		"valid":   valid,
+		"invalid": invalid,
+		"note":    "invalid 中的项未通过校验,请修正后重试;valid 为已通过校验的项。",
+	})
+}
+
 // EmptyResult 空结果
 func EmptyResult(msg string) *mcp.CallToolResult {
 	return mcp.NewToolResultText(msg)
 }
+
+// PageResult 将分页查询结果包装为 AI 友好的结构:在原始 data 之外,附带
+// total(符合条件的总数)、pageSize、pageNum、returned(本页返回条数)与
+// hasMore(是否还有下一页),便于 Agent 判断是否翻完、如何继续翻页。
+//
+// hasMore = pageNum*pageSize < total(pageNum 从 1 开始)。
+// data 会经过 PII 脱敏并按大小决定内联或落盘(复用 JSONResultOrFile)。
+// hint 用于落盘文件名前缀。
+func PageResult(total, pageSize, pageNum uint, data any, hint string) *mcp.CallToolResult {
+	returned := 0
+	// 尽力统计本页条数:data 通常是切片
+	switch d := data.(type) {
+	case nil:
+		returned = 0
+	default:
+		if arr, ok := toSliceLen(d); ok {
+			returned = arr
+		}
+	}
+
+	hasMore := uint(pageNum)*uint(pageSize) < uint(total)
+
+	return JSONResultOrFile(map[string]any{
+		"total":    total,
+		"pageSize": pageSize,
+		"pageNum":  pageNum,
+		"returned": returned,
+		"hasMore":  hasMore,
+		"data":     data,
+	}, hint)
+}
+
+// toSliceLen 若 v 是切片或数组,返回其长度与 true;否则返回 (0,false)。
+func toSliceLen(v any) (int, bool) {
+	rv := reflect.ValueOf(v)
+	switch rv.Kind() {
+	case reflect.Slice, reflect.Array:
+		return rv.Len(), true
+	default:
+		return 0, false
+	}
+}

+ 221 - 0
service/cmd/mcp/tools/helper/spill.go

@@ -1,11 +1,13 @@
 package helper
 
 import (
+	"bufio"
 	"encoding/json"
 	"fmt"
 	"os"
 	"path/filepath"
 	"regexp"
+	"sort"
 	"strings"
 	"time"
 
@@ -115,6 +117,10 @@ func BytesResultOrFile(b []byte, hint string) *mcp.CallToolResult {
 		"preview":   truncate(string(b), previewMaxBytes),
 		"note":      "结果较大已写入本地文件,请使用文件读取工具按 localPath 读取完整内容;preview 仅为前部片段。",
 	}
+	// 附带结构化摘要:帮助 Agent 在不读回完整文件的情况下了解数据规模与字段,节省 token。
+	if summary := summarizeJSON(b); summary != nil {
+		meta["summary"] = summary
+	}
 	mb, err := json.MarshalIndent(meta, "", "  ")
 	if err != nil {
 		return mcp.NewToolResultText(string(b))
@@ -122,6 +128,221 @@ func BytesResultOrFile(b []byte, hint string) *mcp.CallToolResult {
 	return mcp.NewToolResultText(string(mb))
 }
 
+// ExportToFile 用于"全量导出"场景:无论数据大小都落盘为文件,绝不内联。
+// 先做 A+B PII 脱敏(与其它返回路径一致,fail-closed),再写入落盘目录,
+// 返回始终包含 localPath + summary + bytes 的结构化结果(不含数据正文),
+// 供 Agent 按 localPath 读取完整导出内容。
+//
+// 落盘失败时返回错误(导出场景不做内联退化,避免大结果灌爆上下文)。
+func ExportToFile(data any, hint string) *mcp.CallToolResult {
+	b, err := marshalSanitized(data)
+	if err != nil {
+		return mcp.NewToolResultError(err.Error())
+	}
+
+	path, werr := writeSpillFile(hint, b)
+	if werr != nil {
+		return mcp.NewToolResultError(fmt.Sprintf("导出落盘失败: %v", werr))
+	}
+
+	meta := map[string]any{
+		"exported":  true,
+		"localPath": path,
+		"bytes":     len(b),
+		"note":      "全量导出结果已写入本地文件,请使用文件读取工具按 localPath 读取完整内容。",
+	}
+	if summary := summarizeJSON(b); summary != nil {
+		meta["summary"] = summary
+	}
+	mb, err := json.MarshalIndent(meta, "", "  ")
+	if err != nil {
+		return mcp.NewToolResultError(fmt.Sprintf("导出元信息序列化失败: %v", err))
+	}
+	return mcp.NewToolResultText(string(mb))
+}
+
+// marshalSanitizedCompact 对单条数据做 A+B 脱敏并返回紧凑(无缩进/无换行)的 JSON 字节,
+// 适用于 NDJSON 逐行写入。
+//   - 强类型命中:sanitizeStrong 后 json.Marshal(紧凑)。
+//   - 否则:json.Marshal 后 MaskJSONBytes(其输出本身即紧凑)。
+func marshalSanitizedCompact(data any) ([]byte, error) {
+	if sanitized, ok := sanitizeStrong(data); ok {
+		b, err := json.Marshal(sanitized)
+		if err != nil {
+			return nil, fmt.Errorf("序列化失败: %v", err)
+		}
+		return b, nil
+	}
+	b, err := json.Marshal(data)
+	if err != nil {
+		return nil, fmt.Errorf("序列化失败: %v", err)
+	}
+	return MaskJSONBytes(b)
+}
+
+// ExportStreamNDJSON 流式导出:逐条消费 next(),边脱敏边写入 NDJSON 文件(每行一个 JSON 对象),
+// 内存占用为常数级(任意时刻仅持有一条记录),适合超大数据量导出。
+//
+// next 语义:返回 (记录, more, err)。
+//   - more=false 且 err=nil:正常结束。
+//   - err!=nil:迭代出错,导出中止并清理半成品文件。
+//
+// 任一条脱敏/写入失败:删除半成品文件并返回错误(fail-closed,绝不留下未脱敏残片)。
+// 成功返回 {exported:true, format:"ndjson", localPath, count, summary}(不含数据正文)。
+func ExportStreamNDJSON(next func() (any, bool, error), hint string) *mcp.CallToolResult {
+	if err := os.MkdirAll(spillDir, 0755); err != nil {
+		return mcp.NewToolResultError(fmt.Sprintf("创建导出目录失败: %v", err))
+	}
+	name := fmt.Sprintf("%s_%d.ndjson", sanitizeHint(hint), time.Now().UnixNano())
+	path := filepath.Join(spillDir, name)
+
+	f, err := os.Create(path)
+	if err != nil {
+		return mcp.NewToolResultError(fmt.Sprintf("创建导出文件失败: %v", err))
+	}
+
+	// 失败时清理半成品文件的辅助闭包
+	failClose := func(msg string) *mcp.CallToolResult {
+		f.Close()
+		_ = os.Remove(path)
+		return mcp.NewToolResultError(msg)
+	}
+
+	w := bufio.NewWriter(f)
+	var count int64
+	var fields []string // 采样首条记录的字段名用于 summary
+
+	for {
+		rec, more, ierr := next()
+		if ierr != nil {
+			return failClose(fmt.Sprintf("导出迭代失败: %v", ierr))
+		}
+		if !more {
+			break
+		}
+
+		line, merr := marshalSanitizedCompact(rec)
+		if merr != nil {
+			return failClose(merr.Error())
+		}
+
+		// 采样首条字段名(尽力而为,失败不影响导出)
+		if count == 0 {
+			var m map[string]any
+			if json.Unmarshal(line, &m) == nil {
+				fields = sortedKeys(m)
+			}
+		}
+
+		if _, werr := w.Write(line); werr != nil {
+			return failClose(fmt.Sprintf("写入导出文件失败: %v", werr))
+		}
+		if werr := w.WriteByte('\n'); werr != nil {
+			return failClose(fmt.Sprintf("写入导出文件失败: %v", werr))
+		}
+		count++
+	}
+
+	if werr := w.Flush(); werr != nil {
+		return failClose(fmt.Sprintf("刷新导出文件失败: %v", werr))
+	}
+	if cerr := f.Close(); cerr != nil {
+		_ = os.Remove(path)
+		return mcp.NewToolResultError(fmt.Sprintf("关闭导出文件失败: %v", cerr))
+	}
+
+	if count == 0 {
+		_ = os.Remove(path)
+		return EmptyResult("符合条件的记录为 0,未生成导出文件")
+	}
+
+	meta := map[string]any{
+		"exported":  true,
+		"format":    "ndjson",
+		"localPath": path,
+		"count":     count,
+		"note":      "全量导出结果已写入 NDJSON 文件(每行一个 JSON 对象),请使用文件读取工具按 localPath 逐行读取解析。",
+	}
+	if len(fields) > 0 {
+		meta["summary"] = map[string]any{
+			"topType":  "ndjson",
+			"count":    count,
+			"itemType": "object",
+			"fields":   fields,
+		}
+	}
+	mb, err := json.MarshalIndent(meta, "", "  ")
+	if err != nil {
+		return mcp.NewToolResultError(fmt.Sprintf("导出元信息序列化失败: %v", err))
+	}
+	return mcp.NewToolResultText(string(mb))
+}
+
+// summarizeJSON 尝试从已序列化的 JSON 字节中提取结构化摘要,供 Agent 快速了解
+// 落盘数据的规模与结构,从而尽量避免读回完整文件。解析失败时返回 nil(不影响主流程)。
+//
+// 摘要内容:
+//   - topType: 顶层类型(array / object / scalar)
+//   - 顶层为数组:count(元素个数)、itemType、fields(首个对象元素的字段名,采样)
+//   - 顶层为对象:fields(顶层字段名)
+func summarizeJSON(b []byte) map[string]any {
+	var v any
+	dec := json.NewDecoder(strings.NewReader(string(b)))
+	dec.UseNumber()
+	if err := dec.Decode(&v); err != nil {
+		return nil
+	}
+
+	switch t := v.(type) {
+	case []any:
+		s := map[string]any{
+			"topType": "array",
+			"count":   len(t),
+		}
+		if len(t) > 0 {
+			if obj, ok := t[0].(map[string]any); ok {
+				s["itemType"] = "object"
+				s["fields"] = sortedKeys(obj)
+			} else {
+				s["itemType"] = scalarKind(t[0])
+			}
+		}
+		return s
+	case map[string]any:
+		return map[string]any{
+			"topType": "object",
+			"fields":  sortedKeys(t),
+		}
+	default:
+		return map[string]any{"topType": "scalar"}
+	}
+}
+
+// sortedKeys 返回 map 的键列表(稳定排序,便于 Agent 阅读与 diff)。
+func sortedKeys(m map[string]any) []string {
+	keys := make([]string, 0, len(m))
+	for k := range m {
+		keys = append(keys, k)
+	}
+	sort.Strings(keys)
+	return keys
+}
+
+// scalarKind 返回标量的粗粒度类型名。
+func scalarKind(v any) string {
+	switch v.(type) {
+	case string:
+		return "string"
+	case bool:
+		return "bool"
+	case nil:
+		return "null"
+	default:
+		// json.Number 及其它
+		return "number"
+	}
+}
+
 // writeSpillFile 将 bytes 写入落盘目录,返回绝对路径。
 func writeSpillFile(hint string, b []byte) (string, error) {
 	if err := os.MkdirAll(spillDir, 0755); err != nil {

+ 1 - 1
service/cmd/mcp/tools/service/auto_update.go

@@ -245,7 +245,7 @@ func handleAutoUpdateFileList(ctx context.Context, req mcp.CallToolRequest) (*mc
 	if err != nil {
 		return helper.ServiceError("查询配置文件列表", err), nil
 	}
-	return helper.JSONResultOrFileAuto(data), nil
+	return helper.PageResult(data.Total, data.PageSize, data.PageNum, data.Data, "auto_update_file_list"), nil
 }
 
 func handleAutoUpdateCreateFile(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {

+ 21 - 9
service/cmd/mcp/tools/service/batch_download.go

@@ -3,6 +3,7 @@ package service
 import (
 	"context"
 	"encoding/json"
+	"strings"
 
 	"github.com/mark3labs/mcp-go/mcp"
 	"github.com/mark3labs/mcp-go/server"
@@ -28,7 +29,8 @@ func registerBatchDownloadTools(s *server.MCPServer) {
 	s.AddTool(
 		mcp.NewTool("service_batch_download_create",
 			mcp.WithDescription("创建批量下载任务(自动入队执行)[写操作]"),
-			mcp.WithString("sn", mcp.Required(), mcp.Description("站点SN列表,JSON数组格式")),
+			mcp.WithArray("sn", mcp.Required(), mcp.WithStringItems(),
+				mcp.Description("站点SN列表,字符串数组,每个SN标准长度20位")),
 			mcp.WithString("time_period", mcp.Required(), mcp.Description("时间范围,JSON数组 [\"2024-01-01\",\"2024-01-31\"]")),
 			mcp.WithString("options", mcp.Required(), mcp.Description("下载选项 JSON,字段: powerFlow(bool), mqttLogs(string[]), eventAlarm(bool), alarmSnapshot(bool), emsLogs(string[]), faultRecord(bool), updateLog(bool), settings(bool)")),
 			mcp.WithString("description", mcp.Description("任务描述")),
@@ -97,13 +99,13 @@ func handleBatchDownloadList(ctx context.Context, req mcp.CallToolRequest) (*mcp
 	if err != nil {
 		return helper.ServiceError("查询批量下载列表", err), nil
 	}
-	return helper.JSONResultOrFileAuto(data), nil
+	return helper.PageResult(data.Total, data.PageSize, data.PageNum, data.Data, "batch_download_list"), nil
 }
 
 func handleBatchDownloadCreate(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-	snStr, err := req.RequireString("sn")
-	if err != nil {
-		return helper.ParamError("sn"), nil
+	snList, ok := helper.GetStringSlice(req, "sn")
+	if !ok || len(snList) == 0 {
+		return helper.ParamInvalidError("sn", "需要非空的字符串数组,如 [\"SN1\",\"SN2\"]"), nil
 	}
 	timePeriodStr, err := req.RequireString("time_period")
 	if err != nil {
@@ -114,9 +116,19 @@ func handleBatchDownloadCreate(ctx context.Context, req mcp.CallToolRequest) (*m
 		return helper.ParamError("options"), nil
 	}
 
-	var snList []string
-	if err := json.Unmarshal([]byte(snStr), &snList); err != nil {
-		return helper.ParamInvalidError("sn", "需要JSON数组格式"), nil
+	// 批量校验 SN:标准长度 20 位。非法项通过结构化错误返回,便于 Agent 精确修正。
+	valid := make([]string, 0, len(snList))
+	invalid := make([]string, 0)
+	for _, sn := range snList {
+		s := strings.TrimSpace(sn)
+		if len(s) < 20 {
+			invalid = append(invalid, sn)
+		} else {
+			valid = append(valid, s)
+		}
+	}
+	if len(invalid) > 0 {
+		return helper.BatchValidationError("创建批量下载任务", valid, invalid, "SN 长度不足,标准长度为 20 位"), nil
 	}
 
 	var timePeriod [2]string
@@ -132,7 +144,7 @@ func handleBatchDownloadCreate(ctx context.Context, req mcp.CallToolRequest) (*m
 	description := req.GetString("description", "")
 
 	config := domain.BatchDownloadConfigWrite{
-		Sn:          snList,
+		Sn:          valid,
 		Description: description,
 		TimePeriod:  timePeriod,
 		Options:     options,

+ 1 - 1
service/cmd/mcp/tools/service/sim.go

@@ -94,5 +94,5 @@ func handleSimSearch(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToo
 		return helper.ServiceError("搜索SIM卡列表", err), nil
 	}
 
-	return helper.JSONResultOrFileAuto(data), nil
+	return helper.PageResult(data.Total, data.PageSize, data.PageNum, data.Data, "sim_search"), nil
 }

+ 1 - 1
service/cmd/mcp/tools/service/site.go

@@ -72,7 +72,7 @@ func handleSiteSearch(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallTo
 		return helper.ServiceError("搜索站点列表", err), nil
 	}
 
-	return helper.JSONResultOrFileAuto(data), nil
+	return helper.PageResult(data.Total, data.PageSize, data.PageNum, data.Data, "site_search"), nil
 }
 
 func handleSitePowerFlow(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {

+ 1 - 1
service/cmd/mcp/tools/service/site_analysis.go

@@ -89,7 +89,7 @@ func handleSiteAnalysisList(ctx context.Context, req mcp.CallToolRequest) (*mcp.
 	if err != nil {
 		return helper.ServiceError("查询站点分析列表", err), nil
 	}
-	return helper.JSONResultOrFileAuto(data), nil
+	return helper.PageResult(data.Total, data.PageSize, data.PageNum, data.Data, "site_analysis_list"), nil
 }
 
 func handleSiteAnalysisGetConfig(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {

+ 1 - 1
service/cmd/mcp/tools/service/site_info.go

@@ -70,7 +70,7 @@ func handleSiteInfoTagsList(ctx context.Context, req mcp.CallToolRequest) (*mcp.
 	if err != nil {
 		return helper.ServiceError("查询标签列表", err), nil
 	}
-	return helper.JSONResultOrFileAuto(data), nil
+	return helper.PageResult(data.Total, data.PageSize, data.PageNum, data.Data, "site_info_tags"), nil
 }
 
 func handleSiteInfoTagUpdate(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {

+ 5 - 8
service/cmd/mcp/tools/service/site_monitor.go

@@ -42,7 +42,8 @@ func registerSiteMonitorTools(s *server.MCPServer) {
 			mcp.WithDescription("创建站点监控配置(包含监控名、描述、站点SN列表)[写操作]"),
 			mcp.WithString("name", mcp.Required(), mcp.Description("监控配置名称")),
 			mcp.WithString("description", mcp.Description("配置描述")),
-			mcp.WithString("site_list", mcp.Required(), mcp.Description("站点SN列表,JSON数组格式,如 [\"SN1\",\"SN2\"]")),
+			mcp.WithArray("site_list", mcp.Required(), mcp.WithStringItems(),
+				mcp.Description("站点SN列表,字符串数组,如 [\"SN1\",\"SN2\"]")),
 		),
 		handleSiteMonitorConfigCreate,
 	)
@@ -127,14 +128,10 @@ func handleSiteMonitorConfigCreate(ctx context.Context, req mcp.CallToolRequest)
 		return helper.ParamError("name"), nil
 	}
 	description := req.GetString("description", "")
-	siteListStr, err := req.RequireString("site_list")
-	if err != nil {
-		return helper.ParamError("site_list"), nil
-	}
 
-	var siteList []string
-	if err := json.Unmarshal([]byte(siteListStr), &siteList); err != nil {
-		return helper.ParamInvalidError("site_list", "需要JSON数组格式"), nil
+	siteList, ok := helper.GetStringSlice(req, "site_list")
+	if !ok || len(siteList) == 0 {
+		return helper.ParamInvalidError("site_list", "需要非空的字符串数组,如 [\"SN1\",\"SN2\"]"), nil
 	}
 
 	config := domain.SitesMonitorConfig{

+ 1 - 1
service/cmd/mcp/tools/service/tou_template.go

@@ -94,7 +94,7 @@ func handleTouTemplateList(ctx context.Context, req mcp.CallToolRequest) (*mcp.C
 		return helper.ServiceError("查询TOU模版列表", err), nil
 	}
 
-	return helper.JSONResultOrFileAuto(data), nil
+	return helper.PageResult(data.Total, data.PageSize, data.PageNum, data.Data, "tou_template_list"), nil
 }
 
 func handleTouTemplateDetail(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {

+ 2 - 2
service/repositories/mongo/simCardManageRepo.go

@@ -80,8 +80,8 @@ func GetSimCardBatchInfo(page, size uint, search string, status *string, platfor
 		infoList = append(infoList, info)
 	}
 
-	// 查询总数
-	total, err := collection.CountDocuments(ctx, bson.M{})
+	// 查询总数(须与查询条件一致,否则带 search/status/platform 筛选时 total 不准)
+	total, err := collection.CountDocuments(ctx, filter)
 	if err != nil {
 		return nil, err
 	}