mongo.go 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528
  1. package data
  2. import (
  3. "context"
  4. "encoding/json"
  5. "fmt"
  6. "time"
  7. "github.com/mark3labs/mcp-go/mcp"
  8. "github.com/mark3labs/mcp-go/server"
  9. "go.mongodb.org/mongo-driver/bson"
  10. "go.mongodb.org/mongo-driver/mongo/options"
  11. "fwh-toolbox-server/cmd/mcp/tools/helper"
  12. "fwh-toolbox-server/database"
  13. )
  14. // mongoAllowedCollections 允许查询的 collection 白名单
  15. var mongoAllowedCollections = []string{
  16. // 站点信息管理
  17. "SiteInfoManageData",
  18. "SiteInfoManageRecord",
  19. // SIM卡管理
  20. "SimCardManage",
  21. "SimCardMonthlyData",
  22. // 批量下载
  23. "BatchDownloadConfig",
  24. // 站点分析
  25. "SiteAnalysisConfig",
  26. // 统计
  27. "StatisticsShutdown",
  28. "StatisticsAlarm",
  29. // 站点监控
  30. "SiteMonitorConfig",
  31. // 自动升级
  32. "AutoUpdateConfigOption",
  33. "AutoUpdateConfig",
  34. // 注意:UserInfo(内部用户身份数据)与 AiChatSessions(历史对话自由文本,
  35. // 字段级脱敏无效)已从白名单移除,禁止通过 data_mongo_* 直查以防隐私泄露。
  36. }
  37. func registerMongoTools(s *server.MCPServer) {
  38. s.AddTool(
  39. mcp.NewTool("data_mongo_find",
  40. mcp.WithDescription("直接查询 MongoDB 集合数据(只读)。用于 service 层无法满足的灵活查询场景。"),
  41. mcp.WithString("collection", mcp.Required(), mcp.Description(
  42. fmt.Sprintf("集合名,可选值: %v", mongoAllowedCollections),
  43. )),
  44. mcp.WithString("filter", mcp.Description("查询条件,JSON 格式的 MongoDB filter(如 {\"sn\": \"xxx\"}),不传则查全部")),
  45. mcp.WithArray("fields", mcp.WithStringItems(),
  46. mcp.Description("字段投影:要返回的字段名数组(如 [\"sn\",\"status\"]),留空返回全部字段。用于大数据量场景下减少返回体积、节省 token。")),
  47. mcp.WithNumber("limit", mcp.Description("返回数量限制,默认10,最大100")),
  48. mcp.WithNumber("skip", mcp.Description("跳过数量(分页偏移),默认0")),
  49. mcp.WithString("sort", mcp.Description("排序条件,JSON 格式(如 {\"createdAt\": -1})")),
  50. ),
  51. handleMongoFind,
  52. )
  53. s.AddTool(
  54. mcp.NewTool("data_mongo_count",
  55. mcp.WithDescription("统计 MongoDB 集合中符合条件的文档数量"),
  56. mcp.WithString("collection", mcp.Required(), mcp.Description(
  57. fmt.Sprintf("集合名,可选值: %v", mongoAllowedCollections),
  58. )),
  59. mcp.WithString("filter", mcp.Description("查询条件,JSON 格式的 MongoDB filter,不传则统计全部")),
  60. ),
  61. handleMongoCount,
  62. )
  63. s.AddTool(
  64. mcp.NewTool("data_mongo_export",
  65. mcp.WithDescription("全量导出 MongoDB 集合数据到本地文件(只读)。用于大数据量导出场景:不受 find 的 100 条上限限制,游标流式拉取符合条件的全部文档,边脱敏边写入 NDJSON 文件(每行一个 JSON 对象),内存占用为常数级。仅返回 localPath、count 与结构化摘要(不返回数据正文,避免占用上下文)。为防止误导出全表,无 filter 时若总数超过上限会被拒绝。"),
  66. mcp.WithString("collection", mcp.Required(), mcp.Description(
  67. fmt.Sprintf("集合名,可选值: %v", mongoAllowedCollections),
  68. )),
  69. mcp.WithString("filter", mcp.Description("查询条件,JSON 格式的 MongoDB filter,不传则导出全部(受上限保护)")),
  70. mcp.WithArray("fields", mcp.WithStringItems(),
  71. mcp.Description("字段投影:只导出这些字段(如 [\"sn\",\"status\"]),留空导出全部字段。")),
  72. mcp.WithString("sort", mcp.Description("排序条件,JSON 格式(如 {\"createdAt\": -1})")),
  73. mcp.WithNumber("max_docs", mcp.Description(fmt.Sprintf("最大导出条数上限,默认 %d,硬上限 %d。超过则拒绝,请缩小 filter。", mongoExportDefaultMax, mongoExportHardMax))),
  74. ),
  75. handleMongoExport,
  76. )
  77. s.AddTool(
  78. mcp.NewTool("data_mongo_aggregate",
  79. mcp.WithDescription("对 MongoDB 集合执行聚合管道(aggregation pipeline,只读)并将结果流式导出到本地文件。"+
  80. "支持 $match/$project/$group/$addFields/$lookup(跨集合关联/JOIN)/$sort/$limit/$unwind/$facet 等只读阶段,"+
  81. "可在数据库侧一次性完成过滤+关联+聚合,避免多次取数与 Agent 侧拼接。"+
  82. "结果边脱敏边写入 NDJSON 文件(每行一个 JSON 对象),仅返回 localPath+count+summary(不返回数据正文)。"+
  83. "安全约束:collection 与 $lookup/$unionWith/$graphLookup 引用的集合都必须在白名单内;"+
  84. "禁止 $out/$merge/$function/$accumulator/$where 等写入或执行代码的阶段。"),
  85. mcp.WithString("collection", mcp.Required(), mcp.Description(
  86. fmt.Sprintf("聚合起点集合名,可选值: %v", mongoAllowedCollections),
  87. )),
  88. mcp.WithString("pipeline", mcp.Required(), mcp.Description(
  89. "聚合管道,JSON 数组字符串,每个元素是一个聚合阶段。"+
  90. "例:[{\"$match\":{\"province\":\"Puerto Rico\"}},{\"$group\":{\"_id\":\"$province\",\"n\":{\"$sum\":1}}}]")),
  91. mcp.WithNumber("max_docs", mcp.Description(fmt.Sprintf(
  92. "最大输出条数上限,默认 %d,硬上限 %d。聚合结果超过上限会被截断并在返回中标注。", mongoAggregateDefaultMax, mongoExportHardMax))),
  93. ),
  94. handleMongoAggregate,
  95. )
  96. }
  97. // mongoAggregateDefaultMax 聚合输出默认条数上限(软保护,可由 max_docs 覆盖)。
  98. const mongoAggregateDefaultMax = 50000
  99. // mongoAllowedStages 聚合管道允许的只读阶段白名单。
  100. // 明确排除写入/执行代码的阶段:$out、$merge(写集合)、$function、$accumulator、$where(执行 JS)。
  101. var mongoAllowedStages = map[string]bool{
  102. "$match": true,
  103. "$project": true,
  104. "$addFields": true,
  105. "$set": true,
  106. "$unset": true,
  107. "$group": true,
  108. "$sort": true,
  109. "$limit": true,
  110. "$skip": true,
  111. "$count": true,
  112. "$unwind": true,
  113. "$lookup": true,
  114. "$graphLookup": true,
  115. "$unionWith": true,
  116. "$facet": true,
  117. "$bucket": true,
  118. "$bucketAuto": true,
  119. "$sortByCount": true,
  120. "$replaceRoot": true,
  121. "$replaceWith": true,
  122. "$sample": true,
  123. "$redact": true,
  124. "$setWindowFields": true,
  125. }
  126. // mongoExportDefaultMax 全量导出默认上限(防误导出全表的软保护,可由 max_docs 覆盖)
  127. const mongoExportDefaultMax = 50000
  128. // mongoExportHardMax 全量导出硬上限。流式导出内存为常数级,此上限仅用于防止
  129. // 误导出超大规模数据,而非内存保护,故设置较高。
  130. const mongoExportHardMax = 5000000
  131. func handleMongoFind(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
  132. collection, err := req.RequireString("collection")
  133. if err != nil {
  134. return helper.ParamError("collection"), nil
  135. }
  136. if !isAllowedCollection(collection) {
  137. return helper.ParamInvalidError("collection", fmt.Sprintf("不允许查询此集合,可选: %v", mongoAllowedCollections)), nil
  138. }
  139. // 解析 filter
  140. filterStr := req.GetString("filter", "{}")
  141. var filter bson.M
  142. if err := json.Unmarshal([]byte(filterStr), &filter); err != nil {
  143. return helper.ParamInvalidError("filter", fmt.Sprintf("JSON 解析失败: %v", err)), nil
  144. }
  145. // limit
  146. limit := int64(req.GetFloat("limit", 10))
  147. if limit > 100 {
  148. limit = 100
  149. }
  150. if limit < 1 {
  151. limit = 10
  152. }
  153. // skip
  154. skip := int64(req.GetFloat("skip", 0))
  155. if skip < 0 {
  156. skip = 0
  157. }
  158. // sort
  159. var sort bson.M
  160. sortStr := req.GetString("sort", "")
  161. if sortStr != "" {
  162. if err := json.Unmarshal([]byte(sortStr), &sort); err != nil {
  163. return helper.ParamInvalidError("sort", fmt.Sprintf("JSON 解析失败: %v", err)), nil
  164. }
  165. }
  166. // 查询
  167. coll := database.GetCollection(collection)
  168. findOpts := options.Find().SetLimit(limit).SetSkip(skip)
  169. if sort != nil {
  170. findOpts.SetSort(sort)
  171. }
  172. // fields 字段投影:留空返回全部字段;指定则只返回这些字段(源头减量,节省 token)
  173. if fields, ok := helper.GetStringSlice(req, "fields"); ok && len(fields) > 0 {
  174. projection := bson.M{}
  175. for _, f := range fields {
  176. projection[f] = 1
  177. }
  178. findOpts.SetProjection(projection)
  179. }
  180. cursor, err := coll.Find(ctx, filter, findOpts)
  181. if err != nil {
  182. return helper.ServiceError("MongoDB查询", err), nil
  183. }
  184. defer cursor.Close(ctx)
  185. var results []bson.M
  186. if err := cursor.All(ctx, &results); err != nil {
  187. return helper.ServiceError("读取MongoDB结果", err), nil
  188. }
  189. // total:符合 filter 的文档总数(不受 limit/skip 影响),便于大数据量遍历时判断进度
  190. total, err := coll.CountDocuments(ctx, filter)
  191. if err != nil {
  192. return helper.ServiceError("统计MongoDB总数", err), nil
  193. }
  194. returned := int64(len(results))
  195. hasMore := skip+returned < total
  196. // 结构化返回:附带 total/returned/skip/limit/hasMore,便于 AI 判断是否翻完、如何翻页
  197. return helper.JSONResultOrFileAuto(map[string]any{
  198. "total": total,
  199. "returned": returned,
  200. "skip": skip,
  201. "limit": limit,
  202. "hasMore": hasMore,
  203. "items": results,
  204. }), nil
  205. }
  206. func handleMongoCount(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
  207. collection, err := req.RequireString("collection")
  208. if err != nil {
  209. return helper.ParamError("collection"), nil
  210. }
  211. if !isAllowedCollection(collection) {
  212. return helper.ParamInvalidError("collection", fmt.Sprintf("不允许查询此集合,可选: %v", mongoAllowedCollections)), nil
  213. }
  214. filterStr := req.GetString("filter", "{}")
  215. var filter bson.M
  216. if err := json.Unmarshal([]byte(filterStr), &filter); err != nil {
  217. return helper.ParamInvalidError("filter", fmt.Sprintf("JSON 解析失败: %v", err)), nil
  218. }
  219. coll := database.GetCollection(collection)
  220. count, err := coll.CountDocuments(ctx, filter)
  221. if err != nil {
  222. return helper.ServiceError("MongoDB统计", err), nil
  223. }
  224. return helper.JSONResultOrFileAuto(map[string]any{
  225. "collection": collection,
  226. "count": count,
  227. }), nil
  228. }
  229. func handleMongoExport(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
  230. collection, err := req.RequireString("collection")
  231. if err != nil {
  232. return helper.ParamError("collection"), nil
  233. }
  234. if !isAllowedCollection(collection) {
  235. return helper.ParamInvalidError("collection", fmt.Sprintf("不允许查询此集合,可选: %v", mongoAllowedCollections)), nil
  236. }
  237. // 解析 filter
  238. filterStr := req.GetString("filter", "{}")
  239. var filter bson.M
  240. if err := json.Unmarshal([]byte(filterStr), &filter); err != nil {
  241. return helper.ParamInvalidError("filter", fmt.Sprintf("JSON 解析失败: %v", err)), nil
  242. }
  243. hasFilter := len(filter) > 0
  244. // max_docs 上限
  245. maxDocs := int64(req.GetFloat("max_docs", float64(mongoExportDefaultMax)))
  246. if maxDocs < 1 {
  247. maxDocs = mongoExportDefaultMax
  248. }
  249. if maxDocs > mongoExportHardMax {
  250. maxDocs = mongoExportHardMax
  251. }
  252. coll := database.GetCollection(collection)
  253. // 先统计总量,做防误导出保护
  254. total, err := coll.CountDocuments(ctx, filter)
  255. if err != nil {
  256. return helper.ServiceError("统计导出总数", err), nil
  257. }
  258. if total == 0 {
  259. return helper.EmptyResult("符合条件的文档为 0,无需导出"), nil
  260. }
  261. if total > maxDocs {
  262. hint := "请缩小 filter 或调高 max_docs"
  263. if !hasFilter {
  264. hint = "未提供 filter 且数据量过大,请提供 filter 缩小范围,或显式调高 max_docs"
  265. }
  266. return helper.ParamInvalidError("max_docs", fmt.Sprintf(
  267. "符合条件的文档数 %d 超过上限 %d,%s(硬上限 %d)", total, maxDocs, hint, mongoExportHardMax)), nil
  268. }
  269. // 组装查询选项
  270. findOpts := options.Find()
  271. if sortStr := req.GetString("sort", ""); sortStr != "" {
  272. var sort bson.M
  273. if err := json.Unmarshal([]byte(sortStr), &sort); err != nil {
  274. return helper.ParamInvalidError("sort", fmt.Sprintf("JSON 解析失败: %v", err)), nil
  275. }
  276. findOpts.SetSort(sort)
  277. }
  278. if fields, ok := helper.GetStringSlice(req, "fields"); ok && len(fields) > 0 {
  279. projection := bson.M{}
  280. for _, f := range fields {
  281. projection[f] = 1
  282. }
  283. findOpts.SetProjection(projection)
  284. }
  285. // 游标流式导出:逐条 decode → 脱敏 → 写 NDJSON,内存常数级
  286. cursor, err := coll.Find(ctx, filter, findOpts)
  287. if err != nil {
  288. return helper.ServiceError("MongoDB导出查询", err), nil
  289. }
  290. defer cursor.Close(ctx)
  291. res := helper.ExportStreamNDJSON(func() (any, bool, error) {
  292. if !cursor.Next(ctx) {
  293. // Next 返回 false:可能是正常结束,也可能是出错
  294. return nil, false, cursor.Err()
  295. }
  296. var doc bson.M
  297. if err := cursor.Decode(&doc); err != nil {
  298. return nil, false, err
  299. }
  300. return doc, true, nil
  301. }, "mongo_export_"+collection)
  302. return res, nil
  303. }
  304. func handleMongoAggregate(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
  305. collection, err := req.RequireString("collection")
  306. if err != nil {
  307. return helper.ParamError("collection"), nil
  308. }
  309. if !isAllowedCollection(collection) {
  310. return helper.ParamInvalidError("collection", fmt.Sprintf("不允许查询此集合,可选: %v", mongoAllowedCollections)), nil
  311. }
  312. // 解析 pipeline(JSON 数组)
  313. pipelineStr, err := req.RequireString("pipeline")
  314. if err != nil {
  315. return helper.ParamError("pipeline"), nil
  316. }
  317. var pipeline []bson.M
  318. if err := json.Unmarshal([]byte(pipelineStr), &pipeline); err != nil {
  319. return helper.ParamInvalidError("pipeline", fmt.Sprintf("需要 JSON 数组,每个元素是一个聚合阶段: %v", err)), nil
  320. }
  321. if len(pipeline) == 0 {
  322. return helper.ParamInvalidError("pipeline", "pipeline 不能为空"), nil
  323. }
  324. // 安全校验:阶段白名单 + 引用集合白名单(递归)
  325. if verr := validatePipeline(pipeline); verr != "" {
  326. return helper.ParamInvalidError("pipeline", verr), nil
  327. }
  328. // max_docs 输出上限
  329. maxDocs := int64(req.GetFloat("max_docs", float64(mongoAggregateDefaultMax)))
  330. if maxDocs < 1 {
  331. maxDocs = mongoAggregateDefaultMax
  332. }
  333. if maxDocs > mongoExportHardMax {
  334. maxDocs = mongoExportHardMax
  335. }
  336. // 聚合可能较重:设置超时 + allowDiskUse 防止内存溢出。
  337. aggCtx, cancel := context.WithTimeout(ctx, 60*time.Second)
  338. defer cancel()
  339. coll := database.GetCollection(collection)
  340. allowDisk := true
  341. cursor, err := coll.Aggregate(aggCtx, pipeline, &options.AggregateOptions{AllowDiskUse: &allowDisk})
  342. if err != nil {
  343. return helper.ServiceError("MongoDB聚合", err), nil
  344. }
  345. defer cursor.Close(aggCtx)
  346. // 流式:逐条 decode → 脱敏 → 写 NDJSON,内存常数级;达到 maxDocs 即停止。
  347. var emitted int64
  348. res := helper.ExportStreamNDJSON(func() (any, bool, error) {
  349. if emitted >= maxDocs {
  350. return nil, false, nil // 达到上限,正常结束(截断)
  351. }
  352. if !cursor.Next(aggCtx) {
  353. return nil, false, cursor.Err()
  354. }
  355. var doc bson.M
  356. if err := cursor.Decode(&doc); err != nil {
  357. return nil, false, err
  358. }
  359. emitted++
  360. return doc, true, nil
  361. }, "mongo_aggregate_"+collection)
  362. return res, nil
  363. }
  364. // validatePipeline 对聚合管道做安全校验,返回空字符串表示通过,否则返回错误说明。
  365. // - 每个阶段的顶层操作符必须在 mongoAllowedStages 白名单内(排除 $out/$merge/$function 等)。
  366. // - 递归收集 $lookup/$graphLookup/$unionWith 引用的集合,全部必须在集合白名单内,
  367. // 防止借跨集合阶段绕过白名单读取被禁集合。
  368. func validatePipeline(pipeline []bson.M) string {
  369. for i, stage := range pipeline {
  370. if len(stage) != 1 {
  371. return fmt.Sprintf("第 %d 个阶段必须恰好包含一个聚合操作符", i+1)
  372. }
  373. for op, spec := range stage {
  374. if !mongoAllowedStages[op] {
  375. return fmt.Sprintf("第 %d 个阶段使用了不允许的操作符 %q(仅允许只读阶段,禁止 $out/$merge/$function 等)", i+1, op)
  376. }
  377. if refs := collectStageCollectionRefs(op, spec); len(refs) > 0 {
  378. for _, c := range refs {
  379. if !isAllowedCollection(c) {
  380. return fmt.Sprintf("第 %d 个阶段 %s 引用了不在白名单内的集合 %q,可选: %v", i+1, op, c, mongoAllowedCollections)
  381. }
  382. }
  383. }
  384. // $unionWith 可能携带嵌套 pipeline,需递归校验
  385. if op == "$unionWith" {
  386. if sub := extractUnionWithPipeline(spec); sub != nil {
  387. if verr := validatePipeline(sub); verr != "" {
  388. return fmt.Sprintf("第 %d 个阶段 $unionWith 的嵌套 pipeline 校验失败: %s", i+1, verr)
  389. }
  390. }
  391. }
  392. // $lookup 的子 pipeline 形式(带 pipeline 字段)也需递归校验
  393. if op == "$lookup" {
  394. if sub := extractLookupPipeline(spec); sub != nil {
  395. if verr := validatePipeline(sub); verr != "" {
  396. return fmt.Sprintf("第 %d 个阶段 $lookup 的嵌套 pipeline 校验失败: %s", i+1, verr)
  397. }
  398. }
  399. }
  400. }
  401. }
  402. return ""
  403. }
  404. // collectStageCollectionRefs 提取某个阶段引用的外部集合名(用于白名单校验)。
  405. // - $lookup: spec.from(字符串)
  406. // - $graphLookup: spec.from(字符串)
  407. // - $unionWith: 字符串本身,或 spec.coll(字符串)
  408. func collectStageCollectionRefs(op string, spec any) []string {
  409. var refs []string
  410. switch op {
  411. case "$lookup", "$graphLookup":
  412. if m, ok := toMap(spec); ok {
  413. if from, ok := m["from"].(string); ok && from != "" {
  414. refs = append(refs, from)
  415. }
  416. }
  417. case "$unionWith":
  418. if s, ok := spec.(string); ok && s != "" {
  419. refs = append(refs, s)
  420. } else if m, ok := toMap(spec); ok {
  421. if coll, ok := m["coll"].(string); ok && coll != "" {
  422. refs = append(refs, coll)
  423. }
  424. }
  425. }
  426. return refs
  427. }
  428. // extractUnionWithPipeline 提取 $unionWith 的嵌套 pipeline(若有)。
  429. func extractUnionWithPipeline(spec any) []bson.M {
  430. m, ok := toMap(spec)
  431. if !ok {
  432. return nil
  433. }
  434. return toPipeline(m["pipeline"])
  435. }
  436. // extractLookupPipeline 提取 $lookup 的子 pipeline(若有)。
  437. func extractLookupPipeline(spec any) []bson.M {
  438. m, ok := toMap(spec)
  439. if !ok {
  440. return nil
  441. }
  442. return toPipeline(m["pipeline"])
  443. }
  444. // toMap 将 bson.M / map[string]any 统一转为 map[string]any。
  445. func toMap(v any) (map[string]any, bool) {
  446. switch t := v.(type) {
  447. case bson.M:
  448. return map[string]any(t), true
  449. case map[string]any:
  450. return t, true
  451. default:
  452. return nil, false
  453. }
  454. }
  455. // toPipeline 将任意值尽力转换为 []bson.M(聚合子管道)。
  456. func toPipeline(v any) []bson.M {
  457. arr, ok := v.([]any)
  458. if !ok {
  459. return nil
  460. }
  461. out := make([]bson.M, 0, len(arr))
  462. for _, e := range arr {
  463. if m, ok := toMap(e); ok {
  464. out = append(out, bson.M(m))
  465. }
  466. }
  467. return out
  468. }
  469. func isAllowedCollection(name string) bool {
  470. for _, c := range mongoAllowedCollections {
  471. if c == name {
  472. return true
  473. }
  474. }
  475. return false
  476. }