// file_batch.go - get_files_batch + commit_multiple_files // // מטרה: 2 כלים שמייעלים פעולות קבצים מרובות בקריאה אחת. package repo import ( "context" "encoding/base64" "encoding/json" "fmt" "sync" "gitea.com/gitea/gitea-mcp/pkg/gitea" "gitea.com/gitea/gitea-mcp/pkg/log" "gitea.com/gitea/gitea-mcp/pkg/params" "gitea.com/gitea/gitea-mcp/pkg/to" gitea_sdk "code.gitea.io/sdk/gitea" "github.com/mark3labs/mcp-go/mcp" "github.com/mark3labs/mcp-go/server" ) const ( GetFilesBatchToolName = "get_files_batch" CommitMultipleFilesToolName = "commit_multiple_files" MaxFilesPerBatch = 20 MaxFilesPerCommit = 50 MaxBatchTotalSizeBytes = 1024 * 1024 DefaultMaxTotalSizeKB = 500 ) // ============================================================================ // Tool 1: get_files_batch // ============================================================================ var GetFilesBatchTool = mcp.NewTool( GetFilesBatchToolName, mcp.WithDescription( "Get multiple files from a repository in a single response. "+ "Useful when you know exactly which files you need. Files are fetched in parallel. "+ "Max 20 files per call.", ), mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")), mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")), mcp.WithString("ref", mcp.Required(), mcp.Description("ref: branch / tag / commit")), mcp.WithArray("file_paths", mcp.Required(), mcp.Description("List of file paths to fetch (relative to repo root). Max 20 entries."), mcp.Items(map[string]any{"type": "string"}), ), mcp.WithNumber("max_total_size_kb", mcp.Description("Safety cap on total response size in KB. Default 500."), mcp.DefaultNumber(float64(DefaultMaxTotalSizeKB)), ), ) // FileFetchResult — תוצאה לקובץ יחיד. type FileFetchResult struct { Path string `json:"path"` Content string `json:"content,omitempty"` Encoding string `json:"encoding,omitempty"` SHA string `json:"sha,omitempty"` Size int `json:"size"` Error string `json:"error,omitempty"` } // BatchFetchResult — תוצאה מאוחדת של batch. type BatchFetchResult struct { Files []FileFetchResult `json:"files"` TotalFiles int `json:"total_files"` TotalSizeBytes int `json:"total_size_bytes"` Truncated bool `json:"truncated"` TruncatedAt string `json:"truncated_at,omitempty"` } func init() { Tool.RegisterRead(server.ServerTool{ Tool: GetFilesBatchTool, Handler: GetFilesBatchFn, }) Tool.RegisterWrite(server.ServerTool{ Tool: CommitMultipleFilesTool, Handler: CommitMultipleFilesFn, }) } func GetFilesBatchFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { log.Debugf("Called GetFilesBatchFn") args := req.GetArguments() owner, err := params.GetString(args, "owner") if err != nil { return to.ErrorResult(err) } repo, err := params.GetString(args, "repo") if err != nil { return to.ErrorResult(err) } ref, err := params.GetString(args, "ref") if err != nil { return to.ErrorResult(err) } filePathsRaw, ok := args["file_paths"].([]any) if !ok { return to.ErrorResult(fmt.Errorf("file_paths must be an array")) } if len(filePathsRaw) == 0 { return to.ErrorResult(fmt.Errorf("file_paths must not be empty")) } if len(filePathsRaw) > MaxFilesPerBatch { return to.ErrorResult(fmt.Errorf( "too many files (%d > max %d). Use download_repo_archive for whole-repo fetches.", len(filePathsRaw), MaxFilesPerBatch, )) } filePaths := make([]string, 0, len(filePathsRaw)) for _, p := range filePathsRaw { s, ok := p.(string) if !ok || s == "" { return to.ErrorResult(fmt.Errorf("file_paths entries must be non-empty strings")) } filePaths = append(filePaths, s) } maxKB := DefaultMaxTotalSizeKB if v, ok := args["max_total_size_kb"].(float64); ok && v > 0 { maxKB = int(v) } maxBytes := maxKB * 1024 if maxBytes > MaxBatchTotalSizeBytes { maxBytes = MaxBatchTotalSizeBytes } client, err := gitea.ClientFromContext(ctx) if err != nil { return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) } // שליפה במקביל באמצעות goroutines results := make([]FileFetchResult, len(filePaths)) var wg sync.WaitGroup for i, p := range filePaths { wg.Add(1) go func(idx int, path string) { defer wg.Done() res := FileFetchResult{Path: path} content, _, err := client.GetContents(owner, repo, ref, path) if err != nil { res.Error = err.Error() results[idx] = res return } if content != nil { if content.Content != nil { res.Content = *content.Content res.Encoding = "base64" } res.SHA = content.SHA res.Size = int(content.Size) } results[idx] = res }(i, p) } wg.Wait() out := BatchFetchResult{ Files: make([]FileFetchResult, 0, len(results)), } for _, r := range results { out.TotalSizeBytes += len(r.Content) if out.TotalSizeBytes > maxBytes { out.Truncated = true out.TruncatedAt = r.Path break } out.Files = append(out.Files, r) } out.TotalFiles = len(out.Files) jsonBytes, err := json.MarshalIndent(out, "", " ") if err != nil { return to.ErrorResult(fmt.Errorf("marshal result: %w", err)) } return to.TextResult(string(jsonBytes)) } // ============================================================================ // Tool 2: commit_multiple_files // ============================================================================ var CommitMultipleFilesTool = mcp.NewTool( CommitMultipleFilesToolName, mcp.WithDescription( "Atomically commit multiple file changes (create/update/delete) in a single commit. "+ "Max 50 files per commit.", ), mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")), mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")), mcp.WithString("branch", mcp.Required(), mcp.Description("target branch")), mcp.WithString("message", mcp.Required(), mcp.Description("commit message")), mcp.WithString("new_branch", mcp.Description("create new branch (optional)")), mcp.WithArray("files", mcp.Required(), mcp.Description( "Array of file operations. Each item: "+ "{operation: 'create'|'update'|'delete', path: string, content: string (for create/update), sha: string (for update/delete)}. "+ "Max 50 entries.", ), mcp.Items(map[string]any{ "type": "object", "properties": map[string]any{ "operation": map[string]any{ "type": "string", "enum": []string{"create", "update", "delete"}, }, "path": map[string]any{"type": "string"}, "content": map[string]any{"type": "string"}, "sha": map[string]any{"type": "string"}, }, "required": []string{"operation", "path"}, }), ), ) // CommitFilesResponse — תוצאה ל-LLM. type CommitFilesResponse struct { CommitSHA string `json:"commit_sha"` FilesChanged int `json:"files_changed"` Operations []string `json:"operations"` Branch string `json:"branch"` } func CommitMultipleFilesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { log.Debugf("Called CommitMultipleFilesFn") args := req.GetArguments() owner, err := params.GetString(args, "owner") if err != nil { return to.ErrorResult(err) } repo, err := params.GetString(args, "repo") if err != nil { return to.ErrorResult(err) } branch, err := params.GetString(args, "branch") if err != nil { return to.ErrorResult(err) } message, err := params.GetString(args, "message") if err != nil { return to.ErrorResult(err) } newBranch, _ := args["new_branch"].(string) filesRaw, ok := args["files"].([]any) if !ok { return to.ErrorResult(fmt.Errorf("files must be an array")) } if len(filesRaw) == 0 { return to.ErrorResult(fmt.Errorf("files must not be empty")) } if len(filesRaw) > MaxFilesPerCommit { return to.ErrorResult(fmt.Errorf( "too many files (%d > max %d)", len(filesRaw), MaxFilesPerCommit, )) } sdkFiles := make([]gitea_sdk.ChangeFileOperation, 0, len(filesRaw)) operations := make([]string, 0, len(filesRaw)) for i, f := range filesRaw { fileMap, ok := f.(map[string]any) if !ok { return to.ErrorResult(fmt.Errorf("files[%d] must be an object", i)) } opStr, _ := fileMap["operation"].(string) path, _ := fileMap["path"].(string) content, _ := fileMap["content"].(string) sha, _ := fileMap["sha"].(string) if opStr == "" || path == "" { return to.ErrorResult(fmt.Errorf("files[%d]: operation and path are required", i)) } var op gitea_sdk.FileOperationType switch opStr { case "create": op = "create" case "update": op = "update" if sha == "" { return to.ErrorResult(fmt.Errorf("files[%d]: sha required for update", i)) } case "delete": op = "delete" if sha == "" { return to.ErrorResult(fmt.Errorf("files[%d]: sha required for delete", i)) } default: return to.ErrorResult(fmt.Errorf("files[%d]: invalid operation %q", i, opStr)) } sdkFile := gitea_sdk.ChangeFileOperation{ Operation: op, Path: path, SHA: sha, } if op != "delete" { sdkFile.Content = base64.StdEncoding.EncodeToString([]byte(content)) } sdkFiles = append(sdkFiles, sdkFile) operations = append(operations, fmt.Sprintf("%s %s", opStr, path)) } client, err := gitea.ClientFromContext(ctx) if err != nil { return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) } opt := gitea_sdk.ChangeFilesOptions{ Files: sdkFiles, FileOptions: gitea_sdk.FileOptions{ Message: message, BranchName: branch, }, } if newBranch != "" { opt.NewBranchName = newBranch } commitResp, _, err := client.ChangeFiles(owner, repo, opt) if err != nil { return to.ErrorResult(fmt.Errorf("commit files err: %v", err)) } commitSHA := "" if commitResp != nil && commitResp.Commit != nil { commitSHA = commitResp.Commit.SHA } resp := CommitFilesResponse{ CommitSHA: commitSHA, FilesChanged: len(sdkFiles), Operations: operations, Branch: branch, } if newBranch != "" { resp.Branch = newBranch } jsonBytes, err := json.MarshalIndent(resp, "", " ") if err != nil { return to.ErrorResult(fmt.Errorf("marshal result: %w", err)) } return to.TextResult(string(jsonBytes)) }