spill.go 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413
  1. package helper
  2. import (
  3. "bufio"
  4. "encoding/json"
  5. "fmt"
  6. "os"
  7. "path/filepath"
  8. "regexp"
  9. "sort"
  10. "strings"
  11. "time"
  12. "github.com/mark3labs/mcp-go/mcp"
  13. )
  14. // AgentBaseDir 是 MCP Agent 允许读写的本地根目录。
  15. // 输入侧 source_path 校验、输出侧结果落盘都限制在此目录及其子目录内。
  16. const AgentBaseDir = "/tmp/fwh-toolbox-agent"
  17. // spillDir 大结果落盘目录(AgentBaseDir 的子目录)
  18. var spillDir = filepath.Join(AgentBaseDir, "results")
  19. // InlineMaxBytes 结果内联返回的最大字节数,超过则落盘到文件。
  20. // 约 32KB,兼顾避免烧 token 与减少不必要的"读文件"往返。
  21. const InlineMaxBytes = 32 * 1024
  22. // previewMaxBytes 落盘时返回给 Agent 的预览内容最大字节数
  23. const previewMaxBytes = 2000
  24. // hintSanitizer 用于清洗文件名 hint,只保留安全字符
  25. var hintSanitizer = regexp.MustCompile(`[^a-zA-Z0-9_\-]+`)
  26. // JSONResultOrFile 将数据序列化为 JSON,并在序列化前完成 PII 脱敏(A+B):
  27. // - 命中已知强类型:走源头结构体脱敏(优化 A),零额外内存开销。
  28. // - 其余类型:序列化后走流式 token 脱敏(优化 B)。
  29. // - 脱敏失败:返回错误,拒绝输出(fail-closed,绝不泄露原始数据)。
  30. //
  31. // 脱敏完成后:
  32. // - 小结果(<= InlineMaxBytes)直接内联返回文本
  33. // - 大结果写入本地文件,返回包含 localPath + 预览 + 元信息的摘要
  34. //
  35. // hint 用于生成可读的文件名前缀(如 "plane_work_items")。
  36. func JSONResultOrFile(data any, hint string) *mcp.CallToolResult {
  37. b, err := marshalSanitized(data)
  38. if err != nil {
  39. return mcp.NewToolResultError(err.Error())
  40. }
  41. return BytesResultOrFile(b, hint)
  42. }
  43. // marshalSanitized 执行 A+B 脱敏并返回脱敏后的 JSON 字节。
  44. // - 优化 A:命中强类型时先对副本做字段级脱敏,再直接序列化(无需二次处理)。
  45. // - 优化 B:未命中强类型时序列化后做流式 token 脱敏。
  46. func marshalSanitized(data any) ([]byte, error) {
  47. if sanitized, ok := sanitizeStrong(data); ok {
  48. b, err := json.MarshalIndent(sanitized, "", " ")
  49. if err != nil {
  50. return nil, fmt.Errorf("序列化失败: %v", err)
  51. }
  52. return b, nil
  53. }
  54. b, err := json.MarshalIndent(data, "", " ")
  55. if err != nil {
  56. return nil, fmt.Errorf("序列化失败: %v", err)
  57. }
  58. masked, err := MaskJSONBytes(b)
  59. if err != nil {
  60. return nil, err
  61. }
  62. return indentJSON(masked), nil
  63. }
  64. // JSONResultOrFileAuto 与 JSONResultOrFile 相同,但使用通用文件名前缀,
  65. // 便于对现有 helper.JSONResult 调用点做无侵入替换。
  66. func JSONResultOrFileAuto(data any) *mcp.CallToolResult {
  67. return JSONResultOrFile(data, "result")
  68. }
  69. // TextResultOrFile 针对可能较大的纯文本返回值做落盘判断:
  70. // - 小文本直接内联返回
  71. // - 大文本写入本地文件,返回 localPath + 预览
  72. func TextResultOrFile(text string, hint string) *mcp.CallToolResult {
  73. return BytesResultOrFile([]byte(text), hint)
  74. }
  75. // BytesResultOrFileMasked 针对上游返回的原始 JSON 字节(如 Plane API 响应)
  76. // 先做流式 PII 脱敏(优化 B),再走落盘判断。
  77. // - 脱敏失败(含无法解析的非法 JSON):返回错误,拒绝输出(fail-closed)。
  78. //
  79. // 相比"unmarshal 成 any 再 JSONResultOrFile",此路径省去一次反序列化+序列化往返。
  80. func BytesResultOrFileMasked(b []byte, hint string) *mcp.CallToolResult {
  81. masked, err := MaskJSONBytes(b)
  82. if err != nil {
  83. return mcp.NewToolResultError(err.Error())
  84. }
  85. return BytesResultOrFile(indentJSON(masked), hint)
  86. }
  87. // BytesResultOrFile 针对已经序列化好的 JSON/文本 bytes 做落盘判断。
  88. func BytesResultOrFile(b []byte, hint string) *mcp.CallToolResult {
  89. if len(b) <= InlineMaxBytes {
  90. return mcp.NewToolResultText(string(b))
  91. }
  92. path, werr := writeSpillFile(hint, b)
  93. if werr != nil {
  94. // 落盘失败则退化为直接内联返回,保证功能可用(仅牺牲上下文占用)
  95. return mcp.NewToolResultText(string(b))
  96. }
  97. meta := map[string]any{
  98. "spilled": true,
  99. "localPath": path,
  100. "bytes": len(b),
  101. "preview": truncate(string(b), previewMaxBytes),
  102. "note": "结果较大已写入本地文件,请使用文件读取工具按 localPath 读取完整内容;preview 仅为前部片段。",
  103. }
  104. // 附带结构化摘要:帮助 Agent 在不读回完整文件的情况下了解数据规模与字段,节省 token。
  105. if summary := summarizeJSON(b); summary != nil {
  106. meta["summary"] = summary
  107. }
  108. mb, err := json.MarshalIndent(meta, "", " ")
  109. if err != nil {
  110. return mcp.NewToolResultText(string(b))
  111. }
  112. return mcp.NewToolResultText(string(mb))
  113. }
  114. // ExportToFile 用于"全量导出"场景:无论数据大小都落盘为文件,绝不内联。
  115. // 先做 A+B PII 脱敏(与其它返回路径一致,fail-closed),再写入落盘目录,
  116. // 返回始终包含 localPath + summary + bytes 的结构化结果(不含数据正文),
  117. // 供 Agent 按 localPath 读取完整导出内容。
  118. //
  119. // 落盘失败时返回错误(导出场景不做内联退化,避免大结果灌爆上下文)。
  120. func ExportToFile(data any, hint string) *mcp.CallToolResult {
  121. b, err := marshalSanitized(data)
  122. if err != nil {
  123. return mcp.NewToolResultError(err.Error())
  124. }
  125. path, werr := writeSpillFile(hint, b)
  126. if werr != nil {
  127. return mcp.NewToolResultError(fmt.Sprintf("导出落盘失败: %v", werr))
  128. }
  129. meta := map[string]any{
  130. "exported": true,
  131. "localPath": path,
  132. "bytes": len(b),
  133. "note": "全量导出结果已写入本地文件,请使用文件读取工具按 localPath 读取完整内容。",
  134. }
  135. if summary := summarizeJSON(b); summary != nil {
  136. meta["summary"] = summary
  137. }
  138. mb, err := json.MarshalIndent(meta, "", " ")
  139. if err != nil {
  140. return mcp.NewToolResultError(fmt.Sprintf("导出元信息序列化失败: %v", err))
  141. }
  142. return mcp.NewToolResultText(string(mb))
  143. }
  144. // marshalSanitizedCompact 对单条数据做 A+B 脱敏并返回紧凑(无缩进/无换行)的 JSON 字节,
  145. // 适用于 NDJSON 逐行写入。
  146. // - 强类型命中:sanitizeStrong 后 json.Marshal(紧凑)。
  147. // - 否则:json.Marshal 后 MaskJSONBytes(其输出本身即紧凑)。
  148. func marshalSanitizedCompact(data any) ([]byte, error) {
  149. if sanitized, ok := sanitizeStrong(data); ok {
  150. b, err := json.Marshal(sanitized)
  151. if err != nil {
  152. return nil, fmt.Errorf("序列化失败: %v", err)
  153. }
  154. return b, nil
  155. }
  156. b, err := json.Marshal(data)
  157. if err != nil {
  158. return nil, fmt.Errorf("序列化失败: %v", err)
  159. }
  160. return MaskJSONBytes(b)
  161. }
  162. // ExportStreamNDJSON 流式导出:逐条消费 next(),边脱敏边写入 NDJSON 文件(每行一个 JSON 对象),
  163. // 内存占用为常数级(任意时刻仅持有一条记录),适合超大数据量导出。
  164. //
  165. // next 语义:返回 (记录, more, err)。
  166. // - more=false 且 err=nil:正常结束。
  167. // - err!=nil:迭代出错,导出中止并清理半成品文件。
  168. //
  169. // 任一条脱敏/写入失败:删除半成品文件并返回错误(fail-closed,绝不留下未脱敏残片)。
  170. // 成功返回 {exported:true, format:"ndjson", localPath, count, summary}(不含数据正文)。
  171. func ExportStreamNDJSON(next func() (any, bool, error), hint string) *mcp.CallToolResult {
  172. if err := os.MkdirAll(spillDir, 0755); err != nil {
  173. return mcp.NewToolResultError(fmt.Sprintf("创建导出目录失败: %v", err))
  174. }
  175. name := fmt.Sprintf("%s_%d.ndjson", sanitizeHint(hint), time.Now().UnixNano())
  176. path := filepath.Join(spillDir, name)
  177. f, err := os.Create(path)
  178. if err != nil {
  179. return mcp.NewToolResultError(fmt.Sprintf("创建导出文件失败: %v", err))
  180. }
  181. // 失败时清理半成品文件的辅助闭包
  182. failClose := func(msg string) *mcp.CallToolResult {
  183. f.Close()
  184. _ = os.Remove(path)
  185. return mcp.NewToolResultError(msg)
  186. }
  187. w := bufio.NewWriter(f)
  188. var count int64
  189. var fields []string // 采样首条记录的字段名用于 summary
  190. for {
  191. rec, more, ierr := next()
  192. if ierr != nil {
  193. return failClose(fmt.Sprintf("导出迭代失败: %v", ierr))
  194. }
  195. if !more {
  196. break
  197. }
  198. line, merr := marshalSanitizedCompact(rec)
  199. if merr != nil {
  200. return failClose(merr.Error())
  201. }
  202. // 采样首条字段名(尽力而为,失败不影响导出)
  203. if count == 0 {
  204. var m map[string]any
  205. if json.Unmarshal(line, &m) == nil {
  206. fields = sortedKeys(m)
  207. }
  208. }
  209. if _, werr := w.Write(line); werr != nil {
  210. return failClose(fmt.Sprintf("写入导出文件失败: %v", werr))
  211. }
  212. if werr := w.WriteByte('\n'); werr != nil {
  213. return failClose(fmt.Sprintf("写入导出文件失败: %v", werr))
  214. }
  215. count++
  216. }
  217. if werr := w.Flush(); werr != nil {
  218. return failClose(fmt.Sprintf("刷新导出文件失败: %v", werr))
  219. }
  220. if cerr := f.Close(); cerr != nil {
  221. _ = os.Remove(path)
  222. return mcp.NewToolResultError(fmt.Sprintf("关闭导出文件失败: %v", cerr))
  223. }
  224. if count == 0 {
  225. _ = os.Remove(path)
  226. return EmptyResult("符合条件的记录为 0,未生成导出文件")
  227. }
  228. meta := map[string]any{
  229. "exported": true,
  230. "format": "ndjson",
  231. "localPath": path,
  232. "count": count,
  233. "note": "全量导出结果已写入 NDJSON 文件(每行一个 JSON 对象),请使用文件读取工具按 localPath 逐行读取解析。",
  234. }
  235. if len(fields) > 0 {
  236. meta["summary"] = map[string]any{
  237. "topType": "ndjson",
  238. "count": count,
  239. "itemType": "object",
  240. "fields": fields,
  241. }
  242. }
  243. mb, err := json.MarshalIndent(meta, "", " ")
  244. if err != nil {
  245. return mcp.NewToolResultError(fmt.Sprintf("导出元信息序列化失败: %v", err))
  246. }
  247. return mcp.NewToolResultText(string(mb))
  248. }
  249. // summarizeJSON 尝试从已序列化的 JSON 字节中提取结构化摘要,供 Agent 快速了解
  250. // 落盘数据的规模与结构,从而尽量避免读回完整文件。解析失败时返回 nil(不影响主流程)。
  251. //
  252. // 摘要内容:
  253. // - topType: 顶层类型(array / object / scalar)
  254. // - 顶层为数组:count(元素个数)、itemType、fields(首个对象元素的字段名,采样)
  255. // - 顶层为对象:fields(顶层字段名)
  256. func summarizeJSON(b []byte) map[string]any {
  257. var v any
  258. dec := json.NewDecoder(strings.NewReader(string(b)))
  259. dec.UseNumber()
  260. if err := dec.Decode(&v); err != nil {
  261. return nil
  262. }
  263. switch t := v.(type) {
  264. case []any:
  265. s := map[string]any{
  266. "topType": "array",
  267. "count": len(t),
  268. }
  269. if len(t) > 0 {
  270. if obj, ok := t[0].(map[string]any); ok {
  271. s["itemType"] = "object"
  272. s["fields"] = sortedKeys(obj)
  273. } else {
  274. s["itemType"] = scalarKind(t[0])
  275. }
  276. }
  277. return s
  278. case map[string]any:
  279. return map[string]any{
  280. "topType": "object",
  281. "fields": sortedKeys(t),
  282. }
  283. default:
  284. return map[string]any{"topType": "scalar"}
  285. }
  286. }
  287. // sortedKeys 返回 map 的键列表(稳定排序,便于 Agent 阅读与 diff)。
  288. func sortedKeys(m map[string]any) []string {
  289. keys := make([]string, 0, len(m))
  290. for k := range m {
  291. keys = append(keys, k)
  292. }
  293. sort.Strings(keys)
  294. return keys
  295. }
  296. // scalarKind 返回标量的粗粒度类型名。
  297. func scalarKind(v any) string {
  298. switch v.(type) {
  299. case string:
  300. return "string"
  301. case bool:
  302. return "bool"
  303. case nil:
  304. return "null"
  305. default:
  306. // json.Number 及其它
  307. return "number"
  308. }
  309. }
  310. // writeSpillFile 将 bytes 写入落盘目录,返回绝对路径。
  311. func writeSpillFile(hint string, b []byte) (string, error) {
  312. if err := os.MkdirAll(spillDir, 0755); err != nil {
  313. return "", err
  314. }
  315. name := fmt.Sprintf("%s_%d.json", sanitizeHint(hint), time.Now().UnixNano())
  316. path := filepath.Join(spillDir, name)
  317. if err := os.WriteFile(path, b, 0644); err != nil {
  318. return "", err
  319. }
  320. return path, nil
  321. }
  322. // sanitizeHint 清洗文件名前缀,避免路径穿越和非法字符
  323. func sanitizeHint(hint string) string {
  324. hint = hintSanitizer.ReplaceAllString(hint, "_")
  325. hint = strings.Trim(hint, "_")
  326. if hint == "" {
  327. hint = "result"
  328. }
  329. if len(hint) > 64 {
  330. hint = hint[:64]
  331. }
  332. return hint
  333. }
  334. // truncate 截断字符串到指定字节数(按 rune 边界安全截断)
  335. func truncate(s string, max int) string {
  336. if len(s) <= max {
  337. return s
  338. }
  339. // 从 max 处向前回退到 rune 边界
  340. end := max
  341. for end > 0 && !utf8RuneStart(s[end]) {
  342. end--
  343. }
  344. return s[:end] + "\n...(truncated)"
  345. }
  346. // utf8RuneStart 判断字节是否为 UTF-8 编码的起始字节
  347. func utf8RuneStart(b byte) bool {
  348. // 0xxxxxxx (ASCII) 或 11xxxxxx (多字节起始);10xxxxxx 为续字节
  349. return b&0xC0 != 0x80
  350. }
  351. // ValidateSourcePath 校验输入侧的本地文件路径:
  352. // - 必须位于 AgentBaseDir(/tmp/fwh-toolbox-agent)目录或其子目录内
  353. // - 清理 ".." 等路径穿越
  354. //
  355. // 返回清理后的绝对路径;非法路径返回 error。
  356. func ValidateSourcePath(path string) (string, error) {
  357. if path == "" {
  358. return "", fmt.Errorf("路径为空")
  359. }
  360. abs, err := filepath.Abs(filepath.Clean(path))
  361. if err != nil {
  362. return "", fmt.Errorf("路径解析失败: %v", err)
  363. }
  364. base := filepath.Clean(AgentBaseDir)
  365. // 允许 base 本身或 base 的子路径;用分隔符前缀避免 /tmp/fwh-toolbox-agent-evil 误通过
  366. if abs != base && !strings.HasPrefix(abs, base+string(os.PathSeparator)) {
  367. return "", fmt.Errorf("路径不在允许目录内,仅允许 %s 及其子目录", AgentBaseDir)
  368. }
  369. return abs, nil
  370. }