Parcourir la source

feat:mcp的storage增加任意文件读写接口

Jerry Liang il y a 2 mois
Parent
commit
c1eb716580

+ 13 - 2
service/cmd/mcp/.kiro/steering/file-output-rules.md

@@ -57,16 +57,27 @@ $$FILE_DOWNLOAD:{"key":"download/jerry/sess_abc123/a1b2c3d4_report.csv","fileNam
 根据文件用途选择不同的输出方式:
 
 ### 需要给用户下载的文件
-- 使用 `storage_file_write` 工具写入
+
+#### 文本文件(CSV、JSON、Markdown、代码等)
+- 使用 `storage_file_write` 工具写入(最大 20MB)
 - 尽量传入 `userId` 和 `sessionId`
 - 写入成功后输出 `$$FILE_DOWNLOAD:...$$` 标记
 - 适用场景:导出数据、生成报告、分析结果文件
 
+#### 任意类型文件(二进制文件、图片、PDF、压缩包、Excel 等)
+- 使用 `storage_file_upload` 工具上传(最大 100MB)
+- 需要将文件内容 base64 编码后传入 `content_base64` 参数
+- 本地文件可通过 shell 命令 `base64 -w0 <file>` 获取编码内容
+- 尽量传入 `userId` 和 `sessionId`
+- 上传成功后同样输出 `$$FILE_DOWNLOAD:...$$` 标记
+
 ### 临时文件(中间处理、缓存等)
 - 写入 `/temp/fwh-toolbox-agent/` 目录
 - 处理完成后无需输出下载标记
 
 ### 判断标准
-- 用户说"下载"、"导出"、"生成报告"、"保存为文件" → 使用 storage_file_write + 输出标记
+- 用户说"下载"、"导出"、"生成报告"、"保存为文件" → 使用 storage_file_write 或 storage_file_upload + 输出标记
+- 文本内容直接写入 → `storage_file_write`
+- 本地已有文件需要上传 / 二进制文件 → `storage_file_upload`
 - 中间计算结果、日志 → 写入 /temp
 - 不确定时 → 询问用户

+ 107 - 3
service/cmd/mcp/tools/storage/file.go

@@ -2,7 +2,9 @@ package storage
 
 import (
 	"context"
+	"encoding/base64"
 	"fmt"
+	"path/filepath"
 
 	"github.com/mark3labs/mcp-go/mcp"
 	"github.com/mark3labs/mcp-go/server"
@@ -27,17 +29,25 @@ func GetFileStore() *filestore.FileStore {
 func registerFileTools(s *server.MCPServer) {
 	s.AddTool(
 		mcp.NewTool("storage_file_read",
-			mcp.WithDescription("读取用户上传的文件内容。用户通过前端上传的文件存储在 /uploads/ 路径下。"),
+			mcp.WithDescription("读取用户上传的文本文件内容(返回纯文本)。用户通过前端上传的文件存储在 /uploads/ 路径下。仅适用于文本类文件。"),
 			mcp.WithString("path", mcp.Required(), mcp.Description("文件路径,格式为 /uploads/{filename}")),
 		),
 		handleFileRead,
 	)
 
+	s.AddTool(
+		mcp.NewTool("storage_file_read_base64",
+			mcp.WithDescription("读取用户上传的文件内容并返回 base64 编码。支持任意文件类型(图片、PDF、压缩包等)。图片文件读取后可直接用于 AI 视觉分析。"),
+			mcp.WithString("path", mcp.Required(), mcp.Description("文件路径,格式为 /uploads/{filename}")),
+		),
+		handleFileReadBase64,
+	)
+
 	s.AddTool(
 		mcp.NewTool("storage_file_write",
-			mcp.WithDescription("将文本内容写入文件存储(MinIO)。可用于保存分析结果、生成报告等。文件路径会自动添加 /uploads/ 前缀。"),
+			mcp.WithDescription("将文本内容写入文件存储(MinIO)。可用于保存分析结果、生成报告等。最大 20MB。文件路径会自动添加 /uploads/ 前缀。如需上传二进制文件请使用 storage_file_upload。"),
 			mcp.WithString("path", mcp.Required(), mcp.Description("文件路径,格式为 /uploads/{filename}(如 /uploads/report.csv)")),
-			mcp.WithString("content", mcp.Required(), mcp.Description("要写入的文本内容")),
+			mcp.WithString("content", mcp.Required(), mcp.Description("要写入的文本内容(最大 20MB)")),
 			mcp.WithString("userId", mcp.Description("用户标识,用于文件路径隔离(尽量填写,不传则使用默认值 mcp-agent)")),
 			mcp.WithString("sessionId", mcp.Description("会话标识,用于文件路径隔离(尽量填写,不传则使用默认值 mcp-session)")),
 		),
@@ -50,6 +60,17 @@ func registerFileTools(s *server.MCPServer) {
 		),
 		handleFileList,
 	)
+
+	s.AddTool(
+		mcp.NewTool("storage_file_upload",
+			mcp.WithDescription("将任意类型文件上传到文件存储(MinIO)。内容需使用 base64 编码传入。支持二进制文件(图片、PDF、压缩包、Excel 等)和文本文件。最大 100MB。"),
+			mcp.WithString("fileName", mcp.Required(), mcp.Description("目标文件名(如 data.zip、report.pdf、image.png)")),
+			mcp.WithString("content_base64", mcp.Required(), mcp.Description("文件内容的 base64 编码字符串(可通过读取本地文件后 base64 编码获得)")),
+			mcp.WithString("userId", mcp.Description("用户标识,用于文件路径隔离(尽量填写,不传则使用默认值 mcp-agent)")),
+			mcp.WithString("sessionId", mcp.Description("会话标识,用于文件路径隔离(尽量填写,不传则使用默认值 mcp-session)")),
+		),
+		handleFileUpload,
+	)
 }
 
 func handleFileRead(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
@@ -74,6 +95,34 @@ func handleFileRead(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallTool
 	return helper.TextResult(content), nil
 }
 
+func handleFileReadBase64(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
+	path, err := req.RequireString("path")
+	if err != nil {
+		return helper.ParamError("path"), nil
+	}
+
+	if mcpFileStore == nil {
+		return helper.ServiceError("文件存储", fmt.Errorf("未初始化")), nil
+	}
+
+	if !mcpFileStore.HasFile(path) {
+		return helper.EmptyResult(fmt.Sprintf("文件不存在: %s", path)), nil
+	}
+
+	data, err := mcpFileStore.ReadFileBytes(path)
+	if err != nil {
+		return helper.ServiceError("读取文件", err), nil
+	}
+
+	encoded := base64.StdEncoding.EncodeToString(data)
+
+	return helper.JSONResult(map[string]any{
+		"path":           path,
+		"size":           len(data),
+		"content_base64": encoded,
+	}), nil
+}
+
 func handleFileList(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
 	if mcpFileStore == nil {
 		return helper.ServiceError("文件存储", fmt.Errorf("未初始化")), nil
@@ -97,6 +146,12 @@ func handleFileWrite(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToo
 		return helper.ParamError("content"), nil
 	}
 
+	// 限制文本内容大小(最大 20MB)
+	const maxSize = 20 * 1024 * 1024
+	if len(content) > maxSize {
+		return helper.ParamInvalidError("content", fmt.Sprintf("内容大小 %d bytes 超过 20MB 限制", len(content))), nil
+	}
+
 	if mcpFileStore == nil {
 		return helper.ServiceError("文件存储", fmt.Errorf("未初始化")), nil
 	}
@@ -117,3 +172,52 @@ func handleFileWrite(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToo
 		"key":      record.Key,
 	}), nil
 }
+
+func handleFileUpload(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
+	fileName, err := req.RequireString("fileName")
+	if err != nil {
+		return helper.ParamError("fileName"), nil
+	}
+	contentBase64, err := req.RequireString("content_base64")
+	if err != nil {
+		return helper.ParamError("content_base64"), nil
+	}
+
+	if mcpFileStore == nil {
+		return helper.ServiceError("文件存储", fmt.Errorf("未初始化")), nil
+	}
+
+	// base64 解码
+	data, err := base64.StdEncoding.DecodeString(contentBase64)
+	if err != nil {
+		// 尝试 RawStdEncoding(无 padding)
+		data, err = base64.RawStdEncoding.DecodeString(contentBase64)
+		if err != nil {
+			return helper.ParamInvalidError("content_base64", "base64 解码失败"), nil
+		}
+	}
+
+	// 限制文件大小(最大 100MB)
+	const maxSize = 100 * 1024 * 1024
+	if len(data) > maxSize {
+		return helper.ParamInvalidError("content_base64", fmt.Sprintf("文件大小 %d bytes 超过 100MB 限制", len(data))), nil
+	}
+
+	userId := req.GetString("userId", "mcp-agent")
+	sessionId := req.GetString("sessionId", "mcp-session")
+
+	virtualPath := fmt.Sprintf("/uploads/%s", filepath.Base(fileName))
+
+	record, err := mcpFileStore.WriteBytesWithSession(userId, sessionId, virtualPath, data)
+	if err != nil {
+		return helper.ServiceError("上传文件", err), nil
+	}
+
+	return helper.JSONResult(map[string]any{
+		"message":  "文件上传成功",
+		"path":     record.Path,
+		"fileName": record.FileName,
+		"size":     record.Size,
+		"key":      record.Key,
+	}), nil
+}

+ 54 - 4
service/utils/filestore/filestore.go

@@ -1,6 +1,7 @@
 package filestore
 
 import (
+	"bytes"
 	"context"
 	"fmt"
 	"io"
@@ -133,29 +134,78 @@ func (fs *FileStore) WriteBytes(userId, fileName string, data []byte, contentTyp
 	return record, nil
 }
 
+// WriteBytesWithSession 将字节内容上传到 MinIO(支持任意文件类型)
+// objectKey 格式: {prefix}/{userId}/{sessionId}/{uuid}_{fileName}
+func (fs *FileStore) WriteBytesWithSession(userId, sessionId, path string, data []byte) (*FileRecord, error) {
+	fileName := filepath.Base(path)
+	uniqueId := uuid.New().String()[:8]
+	objectKey := fmt.Sprintf("%s/%s/%s/%s_%s", fs.prefix, userId, sessionId, uniqueId, fileName)
+
+	reader := bytes.NewReader(data)
+	ctx := context.Background()
+
+	_, err := minioUtil.Client.PutObject(ctx, AIFileBucket, objectKey, reader, int64(len(data)), minio.PutObjectOptions{
+		ContentType: DetectContentType(fileName),
+		Expires:     time.Now().Add(AIFileExpireDays * 24 * time.Hour),
+	})
+	if err != nil {
+		return nil, fmt.Errorf("上传文件到MinIO失败: %w", err)
+	}
+
+	record := &FileRecord{
+		Key:       objectKey,
+		FileName:  fileName,
+		Path:      path,
+		Size:      int64(len(data)),
+		CreatedAt: time.Now().Unix(),
+	}
+
+	fs.mu.Lock()
+	fs.files[path] = record
+	fs.mu.Unlock()
+
+	zap.L().Info("[FileStore] 文件已保存到MinIO",
+		zap.String("prefix", fs.prefix),
+		zap.String("path", path),
+		zap.String("key", objectKey),
+		zap.String("fileName", fileName),
+	)
+
+	return record, nil
+}
+
 // ReadFile 从 MinIO 读取文件内容
 func (fs *FileStore) ReadFile(path string) (string, error) {
+	data, err := fs.ReadFileBytes(path)
+	if err != nil {
+		return "", err
+	}
+	return string(data), nil
+}
+
+// ReadFileBytes 从 MinIO 读取文件原始字节内容
+func (fs *FileStore) ReadFileBytes(path string) ([]byte, error) {
 	fs.mu.RLock()
 	record, ok := fs.files[path]
 	fs.mu.RUnlock()
 
 	if !ok {
-		return "", fmt.Errorf("文件不存在: %s", path)
+		return nil, fmt.Errorf("文件不存在: %s", path)
 	}
 
 	ctx := context.Background()
 	obj, err := minioUtil.Client.GetObject(ctx, AIFileBucket, record.Key, minio.GetObjectOptions{})
 	if err != nil {
-		return "", fmt.Errorf("从MinIO读取文件失败: %w", err)
+		return nil, fmt.Errorf("从MinIO读取文件失败: %w", err)
 	}
 	defer obj.Close()
 
 	data, err := io.ReadAll(obj)
 	if err != nil {
-		return "", fmt.Errorf("读取文件内容失败: %w", err)
+		return nil, fmt.Errorf("读取文件内容失败: %w", err)
 	}
 
-	return string(data), nil
+	return data, nil
 }
 
 // HasFile 检查路径是否有文件记录