spill.go 4.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151
  1. package helper
  2. import (
  3. "encoding/json"
  4. "fmt"
  5. "os"
  6. "path/filepath"
  7. "regexp"
  8. "strings"
  9. "time"
  10. "github.com/mark3labs/mcp-go/mcp"
  11. )
  12. // AgentBaseDir 是 MCP Agent 允许读写的本地根目录。
  13. // 输入侧 source_path 校验、输出侧结果落盘都限制在此目录及其子目录内。
  14. const AgentBaseDir = "/tmp/fwh-toolbox-agent"
  15. // spillDir 大结果落盘目录(AgentBaseDir 的子目录)
  16. var spillDir = filepath.Join(AgentBaseDir, "results")
  17. // InlineMaxBytes 结果内联返回的最大字节数,超过则落盘到文件。
  18. // 约 32KB,兼顾避免烧 token 与减少不必要的"读文件"往返。
  19. const InlineMaxBytes = 32 * 1024
  20. // previewMaxBytes 落盘时返回给 Agent 的预览内容最大字节数
  21. const previewMaxBytes = 2000
  22. // hintSanitizer 用于清洗文件名 hint,只保留安全字符
  23. var hintSanitizer = regexp.MustCompile(`[^a-zA-Z0-9_\-]+`)
  24. // JSONResultOrFile 将数据序列化为 JSON:
  25. // - 小结果(<= InlineMaxBytes)直接内联返回文本
  26. // - 大结果写入本地文件,返回包含 localPath + 预览 + 元信息的摘要
  27. //
  28. // hint 用于生成可读的文件名前缀(如 "plane_work_items")。
  29. func JSONResultOrFile(data any, hint string) *mcp.CallToolResult {
  30. b, err := json.MarshalIndent(data, "", " ")
  31. if err != nil {
  32. return mcp.NewToolResultError(fmt.Sprintf("序列化失败: %v", err))
  33. }
  34. return BytesResultOrFile(b, hint)
  35. }
  36. // JSONResultOrFileAuto 与 JSONResultOrFile 相同,但使用通用文件名前缀,
  37. // 便于对现有 helper.JSONResult 调用点做无侵入替换。
  38. func JSONResultOrFileAuto(data any) *mcp.CallToolResult {
  39. return JSONResultOrFile(data, "result")
  40. }
  41. // TextResultOrFile 针对可能较大的纯文本返回值做落盘判断:
  42. // - 小文本直接内联返回
  43. // - 大文本写入本地文件,返回 localPath + 预览
  44. func TextResultOrFile(text string, hint string) *mcp.CallToolResult {
  45. return BytesResultOrFile([]byte(text), hint)
  46. }
  47. // BytesResultOrFile 针对已经序列化好的 JSON/文本 bytes 做落盘判断。
  48. func BytesResultOrFile(b []byte, hint string) *mcp.CallToolResult {
  49. if len(b) <= InlineMaxBytes {
  50. return mcp.NewToolResultText(string(b))
  51. }
  52. path, werr := writeSpillFile(hint, b)
  53. if werr != nil {
  54. // 落盘失败则退化为直接内联返回,保证功能可用(仅牺牲上下文占用)
  55. return mcp.NewToolResultText(string(b))
  56. }
  57. meta := map[string]any{
  58. "spilled": true,
  59. "localPath": path,
  60. "bytes": len(b),
  61. "preview": truncate(string(b), previewMaxBytes),
  62. "note": "结果较大已写入本地文件,请使用文件读取工具按 localPath 读取完整内容;preview 仅为前部片段。",
  63. }
  64. mb, err := json.MarshalIndent(meta, "", " ")
  65. if err != nil {
  66. return mcp.NewToolResultText(string(b))
  67. }
  68. return mcp.NewToolResultText(string(mb))
  69. }
  70. // writeSpillFile 将 bytes 写入落盘目录,返回绝对路径。
  71. func writeSpillFile(hint string, b []byte) (string, error) {
  72. if err := os.MkdirAll(spillDir, 0755); err != nil {
  73. return "", err
  74. }
  75. name := fmt.Sprintf("%s_%d.json", sanitizeHint(hint), time.Now().UnixNano())
  76. path := filepath.Join(spillDir, name)
  77. if err := os.WriteFile(path, b, 0644); err != nil {
  78. return "", err
  79. }
  80. return path, nil
  81. }
  82. // sanitizeHint 清洗文件名前缀,避免路径穿越和非法字符
  83. func sanitizeHint(hint string) string {
  84. hint = hintSanitizer.ReplaceAllString(hint, "_")
  85. hint = strings.Trim(hint, "_")
  86. if hint == "" {
  87. hint = "result"
  88. }
  89. if len(hint) > 64 {
  90. hint = hint[:64]
  91. }
  92. return hint
  93. }
  94. // truncate 截断字符串到指定字节数(按 rune 边界安全截断)
  95. func truncate(s string, max int) string {
  96. if len(s) <= max {
  97. return s
  98. }
  99. // 从 max 处向前回退到 rune 边界
  100. end := max
  101. for end > 0 && !utf8RuneStart(s[end]) {
  102. end--
  103. }
  104. return s[:end] + "\n...(truncated)"
  105. }
  106. // utf8RuneStart 判断字节是否为 UTF-8 编码的起始字节
  107. func utf8RuneStart(b byte) bool {
  108. // 0xxxxxxx (ASCII) 或 11xxxxxx (多字节起始);10xxxxxx 为续字节
  109. return b&0xC0 != 0x80
  110. }
  111. // ValidateSourcePath 校验输入侧的本地文件路径:
  112. // - 必须位于 AgentBaseDir(/tmp/fwh-toolbox-agent)目录或其子目录内
  113. // - 清理 ".." 等路径穿越
  114. //
  115. // 返回清理后的绝对路径;非法路径返回 error。
  116. func ValidateSourcePath(path string) (string, error) {
  117. if path == "" {
  118. return "", fmt.Errorf("路径为空")
  119. }
  120. abs, err := filepath.Abs(filepath.Clean(path))
  121. if err != nil {
  122. return "", fmt.Errorf("路径解析失败: %v", err)
  123. }
  124. base := filepath.Clean(AgentBaseDir)
  125. // 允许 base 本身或 base 的子路径;用分隔符前缀避免 /tmp/fwh-toolbox-agent-evil 误通过
  126. if abs != base && !strings.HasPrefix(abs, base+string(os.PathSeparator)) {
  127. return "", fmt.Errorf("路径不在允许目录内,仅允许 %s 及其子目录", AgentBaseDir)
  128. }
  129. return abs, nil
  130. }