|
@@ -12,22 +12,28 @@ import (
|
|
|
"time"
|
|
"time"
|
|
|
|
|
|
|
|
"github.com/mark3labs/mcp-go/mcp"
|
|
"github.com/mark3labs/mcp-go/mcp"
|
|
|
|
|
+
|
|
|
|
|
+ "fwh-toolbox-server/utils/agentfiles"
|
|
|
)
|
|
)
|
|
|
|
|
|
|
|
// AgentBaseDir 是 MCP Agent 允许读写的本地根目录。
|
|
// AgentBaseDir 是 MCP Agent 允许读写的本地根目录。
|
|
|
// 输入侧 source_path 校验、输出侧结果落盘都限制在此目录及其子目录内。
|
|
// 输入侧 source_path 校验、输出侧结果落盘都限制在此目录及其子目录内。
|
|
|
const AgentBaseDir = "/tmp/fwh-toolbox-agent"
|
|
const AgentBaseDir = "/tmp/fwh-toolbox-agent"
|
|
|
|
|
|
|
|
|
|
+// init 断言:本包 AgentBaseDir 必须与 agentfiles.AgentBaseDir 一致,
|
|
|
|
|
+// 否则落盘目录与清理目录不匹配会导致文件永不被清理。不一致时 MCP 启动即 panic,
|
|
|
|
|
+// 让问题在部署时立刻暴露,而非静默漂移。
|
|
|
|
|
+func init() {
|
|
|
|
|
+ if AgentBaseDir != agentfiles.AgentBaseDir {
|
|
|
|
|
+ panic(fmt.Sprintf(
|
|
|
|
|
+ "AgentBaseDir 不一致: helper=%q agentfiles=%q(两处必须相同)",
|
|
|
|
|
+ AgentBaseDir, agentfiles.AgentBaseDir))
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
// spillDir 大结果落盘目录(AgentBaseDir 的子目录)
|
|
// spillDir 大结果落盘目录(AgentBaseDir 的子目录)
|
|
|
var spillDir = filepath.Join(AgentBaseDir, "results")
|
|
var spillDir = filepath.Join(AgentBaseDir, "results")
|
|
|
|
|
|
|
|
-// InlineMaxBytes 结果内联返回的最大字节数,超过则落盘到文件。
|
|
|
|
|
-// 约 32KB,兼顾避免烧 token 与减少不必要的"读文件"往返。
|
|
|
|
|
-const InlineMaxBytes = 32 * 1024
|
|
|
|
|
-
|
|
|
|
|
-// previewMaxBytes 落盘时返回给 Agent 的预览内容最大字节数
|
|
|
|
|
-const previewMaxBytes = 2000
|
|
|
|
|
-
|
|
|
|
|
// hintSanitizer 用于清洗文件名 hint,只保留安全字符
|
|
// hintSanitizer 用于清洗文件名 hint,只保留安全字符
|
|
|
var hintSanitizer = regexp.MustCompile(`[^a-zA-Z0-9_\-]+`)
|
|
var hintSanitizer = regexp.MustCompile(`[^a-zA-Z0-9_\-]+`)
|
|
|
|
|
|
|
@@ -36,9 +42,8 @@ var hintSanitizer = regexp.MustCompile(`[^a-zA-Z0-9_\-]+`)
|
|
|
// - 其余类型:序列化后走流式 token 脱敏(优化 B)。
|
|
// - 其余类型:序列化后走流式 token 脱敏(优化 B)。
|
|
|
// - 脱敏失败:返回错误,拒绝输出(fail-closed,绝不泄露原始数据)。
|
|
// - 脱敏失败:返回错误,拒绝输出(fail-closed,绝不泄露原始数据)。
|
|
|
//
|
|
//
|
|
|
-// 脱敏完成后:
|
|
|
|
|
-// - 小结果(<= InlineMaxBytes)直接内联返回文本
|
|
|
|
|
-// - 大结果写入本地文件,返回包含 localPath + 预览 + 元信息的摘要
|
|
|
|
|
|
|
+// 脱敏完成后无条件落盘(数据类返回统一策略):无论数据大小都写入本地文件,
|
|
|
|
|
+// 返回体只含 localPath + bytes + summary(不含数据正文),避免数据进入模型上下文。
|
|
|
//
|
|
//
|
|
|
// hint 用于生成可读的文件名前缀(如 "plane_work_items")。
|
|
// hint 用于生成可读的文件名前缀(如 "plane_work_items")。
|
|
|
func JSONResultOrFile(data any, hint string) *mcp.CallToolResult {
|
|
func JSONResultOrFile(data any, hint string) *mcp.CallToolResult {
|
|
@@ -78,15 +83,14 @@ func JSONResultOrFileAuto(data any) *mcp.CallToolResult {
|
|
|
return JSONResultOrFile(data, "result")
|
|
return JSONResultOrFile(data, "result")
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-// TextResultOrFile 针对可能较大的纯文本返回值做落盘判断:
|
|
|
|
|
-// - 小文本直接内联返回
|
|
|
|
|
-// - 大文本写入本地文件,返回 localPath + 预览
|
|
|
|
|
|
|
+// TextResultOrFile 针对纯文本返回值无条件落盘(数据类返回统一策略):
|
|
|
|
|
+// 无论文本大小都写入本地文件,返回体只含 localPath + summary(不含正文)。
|
|
|
func TextResultOrFile(text string, hint string) *mcp.CallToolResult {
|
|
func TextResultOrFile(text string, hint string) *mcp.CallToolResult {
|
|
|
return BytesResultOrFile([]byte(text), hint)
|
|
return BytesResultOrFile([]byte(text), hint)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
// BytesResultOrFileMasked 针对上游返回的原始 JSON 字节(如 Plane API 响应)
|
|
// BytesResultOrFileMasked 针对上游返回的原始 JSON 字节(如 Plane API 响应)
|
|
|
-// 先做流式 PII 脱敏(优化 B),再走落盘判断。
|
|
|
|
|
|
|
+// 先做流式 PII 脱敏(优化 B),再无条件落盘(数据类返回统一策略)。
|
|
|
// - 脱敏失败(含无法解析的非法 JSON):返回错误,拒绝输出(fail-closed)。
|
|
// - 脱敏失败(含无法解析的非法 JSON):返回错误,拒绝输出(fail-closed)。
|
|
|
//
|
|
//
|
|
|
// 相比"unmarshal 成 any 再 JSONResultOrFile",此路径省去一次反序列化+序列化往返。
|
|
// 相比"unmarshal 成 any 再 JSONResultOrFile",此路径省去一次反序列化+序列化往返。
|
|
@@ -98,24 +102,29 @@ func BytesResultOrFileMasked(b []byte, hint string) *mcp.CallToolResult {
|
|
|
return BytesResultOrFile(indentJSON(masked), hint)
|
|
return BytesResultOrFile(indentJSON(masked), hint)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-// BytesResultOrFile 针对已经序列化好的 JSON/文本 bytes 做落盘判断。
|
|
|
|
|
|
|
+// BytesResultOrFile 针对已经序列化好的 JSON/文本 bytes 做无条件落盘。
|
|
|
|
|
+//
|
|
|
|
|
+// 语义(数据类返回):无论数据大小都写入本地文件,返回结果只包含
|
|
|
|
|
+// localPath + bytes + summary(不含数据正文),供 Agent 按 localPath 读取完整内容。
|
|
|
|
|
+// 这样任意"带数据"的查询/导出结果都不会进入模型上下文,从根本上避免上下文膨胀。
|
|
|
|
|
+//
|
|
|
|
|
+// 落盘失败时返回错误(不再退化为内联,避免大结果灌爆上下文)。
|
|
|
func BytesResultOrFile(b []byte, hint string) *mcp.CallToolResult {
|
|
func BytesResultOrFile(b []byte, hint string) *mcp.CallToolResult {
|
|
|
- if len(b) <= InlineMaxBytes {
|
|
|
|
|
- return mcp.NewToolResultText(string(b))
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ return spillResult(b, hint)
|
|
|
|
|
+}
|
|
|
|
|
|
|
|
|
|
+// spillResult 是"带数据结果一律落盘"的统一实现:写盘 + 组装 localPath/summary 元信息。
|
|
|
|
|
+// 供 BytesResultOrFile / ExportToFile 等数据类返回路径复用。
|
|
|
|
|
+func spillResult(b []byte, hint string) *mcp.CallToolResult {
|
|
|
path, werr := writeSpillFile(hint, b)
|
|
path, werr := writeSpillFile(hint, b)
|
|
|
if werr != nil {
|
|
if werr != nil {
|
|
|
- // 落盘失败则退化为直接内联返回,保证功能可用(仅牺牲上下文占用)
|
|
|
|
|
- return mcp.NewToolResultText(string(b))
|
|
|
|
|
|
|
+ return mcp.NewToolResultError(fmt.Sprintf("结果落盘失败: %v", werr))
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
meta := map[string]any{
|
|
meta := map[string]any{
|
|
|
- "spilled": true,
|
|
|
|
|
"localPath": path,
|
|
"localPath": path,
|
|
|
"bytes": len(b),
|
|
"bytes": len(b),
|
|
|
- "preview": truncate(string(b), previewMaxBytes),
|
|
|
|
|
- "note": "结果较大已写入本地文件,请使用文件读取工具按 localPath 读取完整内容;preview 仅为前部片段。",
|
|
|
|
|
|
|
+ "note": "结果数据已写入本地文件,请使用文件读取工具按 localPath 读取完整内容。",
|
|
|
}
|
|
}
|
|
|
// 附带结构化摘要:帮助 Agent 在不读回完整文件的情况下了解数据规模与字段,节省 token。
|
|
// 附带结构化摘要:帮助 Agent 在不读回完整文件的情况下了解数据规模与字段,节省 token。
|
|
|
if summary := summarizeJSON(b); summary != nil {
|
|
if summary := summarizeJSON(b); summary != nil {
|
|
@@ -123,42 +132,23 @@ func BytesResultOrFile(b []byte, hint string) *mcp.CallToolResult {
|
|
|
}
|
|
}
|
|
|
mb, err := json.MarshalIndent(meta, "", " ")
|
|
mb, err := json.MarshalIndent(meta, "", " ")
|
|
|
if err != nil {
|
|
if err != nil {
|
|
|
- return mcp.NewToolResultText(string(b))
|
|
|
|
|
|
|
+ return mcp.NewToolResultError(fmt.Sprintf("落盘元信息序列化失败: %v", err))
|
|
|
}
|
|
}
|
|
|
return mcp.NewToolResultText(string(mb))
|
|
return mcp.NewToolResultText(string(mb))
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-// ExportToFile 用于"全量导出"场景:无论数据大小都落盘为文件,绝不内联。
|
|
|
|
|
-// 先做 A+B PII 脱敏(与其它返回路径一致,fail-closed),再写入落盘目录,
|
|
|
|
|
-// 返回始终包含 localPath + summary + bytes 的结构化结果(不含数据正文),
|
|
|
|
|
-// 供 Agent 按 localPath 读取完整导出内容。
|
|
|
|
|
|
|
+// ExportToFile 用于"全量导出"场景:将数据一次性序列化后落盘。
|
|
|
|
|
+// 先做 A+B PII 脱敏(与其它返回路径一致,fail-closed),再走统一的落盘返回,
|
|
|
|
|
+// 结果只包含 localPath + bytes + summary(不含数据正文)。
|
|
|
//
|
|
//
|
|
|
-// 落盘失败时返回错误(导出场景不做内联退化,避免大结果灌爆上下文)。
|
|
|
|
|
|
|
+// 注:数据类返回现已统一为无条件落盘,本函数与 JSONResultOrFile 行为一致,
|
|
|
|
|
+// 保留独立入口以表达"全量导出"语义、便于调用点阅读。
|
|
|
func ExportToFile(data any, hint string) *mcp.CallToolResult {
|
|
func ExportToFile(data any, hint string) *mcp.CallToolResult {
|
|
|
b, err := marshalSanitized(data)
|
|
b, err := marshalSanitized(data)
|
|
|
if err != nil {
|
|
if err != nil {
|
|
|
return mcp.NewToolResultError(err.Error())
|
|
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))
|
|
|
|
|
|
|
+ return spillResult(b, hint)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
// marshalSanitizedCompact 对单条数据做 A+B 脱敏并返回紧凑(无缩进/无换行)的 JSON 字节,
|
|
// marshalSanitizedCompact 对单条数据做 A+B 脱敏并返回紧凑(无缩进/无换行)的 JSON 字节,
|
|
@@ -369,25 +359,6 @@ func sanitizeHint(hint string) string {
|
|
|
return hint
|
|
return hint
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-// truncate 截断字符串到指定字节数(按 rune 边界安全截断)
|
|
|
|
|
-func truncate(s string, max int) string {
|
|
|
|
|
- if len(s) <= max {
|
|
|
|
|
- return s
|
|
|
|
|
- }
|
|
|
|
|
- // 从 max 处向前回退到 rune 边界
|
|
|
|
|
- end := max
|
|
|
|
|
- for end > 0 && !utf8RuneStart(s[end]) {
|
|
|
|
|
- end--
|
|
|
|
|
- }
|
|
|
|
|
- return s[:end] + "\n...(truncated)"
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
-// utf8RuneStart 判断字节是否为 UTF-8 编码的起始字节
|
|
|
|
|
-func utf8RuneStart(b byte) bool {
|
|
|
|
|
- // 0xxxxxxx (ASCII) 或 11xxxxxx (多字节起始);10xxxxxx 为续字节
|
|
|
|
|
- return b&0xC0 != 0x80
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
// ValidateSourcePath 校验输入侧的本地文件路径:
|
|
// ValidateSourcePath 校验输入侧的本地文件路径:
|
|
|
// - 必须位于 AgentBaseDir(/tmp/fwh-toolbox-agent)目录或其子目录内
|
|
// - 必须位于 AgentBaseDir(/tmp/fwh-toolbox-agent)目录或其子目录内
|
|
|
// - 清理 ".." 等路径穿越
|
|
// - 清理 ".." 等路径穿越
|