feat(repo): add get_files_batch and commit_multiple_files tools
Two new tools for efficient multi-file operations: 1. get_files_batch: Fetch up to 20 files in parallel via goroutines. Replaces N individual get_file_contents calls. Built-in safety cap on total response size (default 500KB). 2. commit_multiple_files: Atomic multi-file commit (create/update/delete). Uses Gitea SDK ChangeFiles API. Max 50 files per commit. Enables proper feature commits with single git history entry. Both tools follow Security by Design: input validation, size limits, proper error handling, no path traversal.
This commit is contained in:
@@ -0,0 +1,361 @@
|
||||
// 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))
|
||||
}
|
||||
Reference in New Issue
Block a user