feat(repo): add download_repo_archive tool
Download an entire repository as zip/tar.gz in a single MCP call.
Replaces N+ get_file_contents calls when fetching whole repos.
Calls Gitea's GET /repos/{owner}/{repo}/archive/{ref}.{format} endpoint,
saves bytes to disk at output_path, returns sha256 for integrity.
Safety: 500MB cap, requires absolute output_path, parent dir auto-created.
Performance impact: ~250 calls → 1 call for typical extension repo.
This commit is contained in:
@@ -0,0 +1,201 @@
|
|||||||
|
// archive.go - download_repo_archive tool
|
||||||
|
//
|
||||||
|
// מטרה: הורדת ריפו שלם כ-zip/tar.gz בקריאה אחת.
|
||||||
|
// קוראים ל-Gitea archive endpoint ושומרים ל-disk.
|
||||||
|
|
||||||
|
package repo
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"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"
|
||||||
|
|
||||||
|
"github.com/mark3labs/mcp-go/mcp"
|
||||||
|
"github.com/mark3labs/mcp-go/server"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
DownloadRepoArchiveToolName = "download_repo_archive"
|
||||||
|
|
||||||
|
// MaxArchiveSizeBytes — תקרת ביטחון לגודל ארכיון (500MB).
|
||||||
|
MaxArchiveSizeBytes = 500 * 1024 * 1024
|
||||||
|
)
|
||||||
|
|
||||||
|
var DownloadRepoArchiveTool = mcp.NewTool(
|
||||||
|
DownloadRepoArchiveToolName,
|
||||||
|
mcp.WithDescription(
|
||||||
|
"Download an entire repository as a zip or tar.gz archive in a single call. "+
|
||||||
|
"Much more efficient than calling get_file_contents for each file. "+
|
||||||
|
"Saves to the local filesystem; returns the saved path, size and sha256.",
|
||||||
|
),
|
||||||
|
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 sha")),
|
||||||
|
mcp.WithString("format",
|
||||||
|
mcp.Description("archive format: zip (default) or tar.gz"),
|
||||||
|
mcp.DefaultString("zip"),
|
||||||
|
),
|
||||||
|
mcp.WithString("output_path",
|
||||||
|
mcp.Required(),
|
||||||
|
mcp.Description("absolute path on the host where the archive should be saved"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
Tool.RegisterRead(server.ServerTool{
|
||||||
|
Tool: DownloadRepoArchiveTool,
|
||||||
|
Handler: DownloadRepoArchiveFn,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ArchiveResult — הפלט שמוחזר ל-LLM.
|
||||||
|
type ArchiveResult struct {
|
||||||
|
SavedTo string `json:"saved_to"`
|
||||||
|
SizeBytes int64 `json:"size_bytes"`
|
||||||
|
SHA256 string `json:"sha256"`
|
||||||
|
Format string `json:"format"`
|
||||||
|
Repository string `json:"repository"`
|
||||||
|
Ref string `json:"ref"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func DownloadRepoArchiveFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
log.Debugf("Called DownloadRepoArchiveFn")
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
outputPath, err := params.GetString(args, "output_path")
|
||||||
|
if err != nil {
|
||||||
|
return to.ErrorResult(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
format, _ := args["format"].(string)
|
||||||
|
if format == "" {
|
||||||
|
format = "zip"
|
||||||
|
}
|
||||||
|
if format != "zip" && format != "tar.gz" {
|
||||||
|
return to.ErrorResult(fmt.Errorf("invalid format %q: must be 'zip' or 'tar.gz'", format))
|
||||||
|
}
|
||||||
|
|
||||||
|
// אבטחה: ודא ש-output_path absolute (לא relative שיוכל לדלוף).
|
||||||
|
if !filepath.IsAbs(outputPath) {
|
||||||
|
return to.ErrorResult(fmt.Errorf("output_path must be absolute, got: %s", outputPath))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ודא שתיקיית היעד קיימת
|
||||||
|
parent := filepath.Dir(outputPath)
|
||||||
|
if err := os.MkdirAll(parent, 0o755); err != nil {
|
||||||
|
return to.ErrorResult(fmt.Errorf("create output dir %q: %w", parent, err))
|
||||||
|
}
|
||||||
|
|
||||||
|
// בנה את הנתיב ל-API של Gitea
|
||||||
|
// GET /repos/{owner}/{repo}/archive/{ref}.{format}
|
||||||
|
apiPath := fmt.Sprintf("repos/%s/%s/archive/%s.%s",
|
||||||
|
url.PathEscape(owner),
|
||||||
|
url.PathEscape(repo),
|
||||||
|
ref,
|
||||||
|
format,
|
||||||
|
)
|
||||||
|
|
||||||
|
// קבע accept header לפי format
|
||||||
|
accept := "application/zip"
|
||||||
|
if format == "tar.gz" {
|
||||||
|
accept = "application/x-gzip"
|
||||||
|
}
|
||||||
|
|
||||||
|
// השתמש ב-DoBytes הקיים ב-rest.go
|
||||||
|
archiveBytes, statusCode, err := gitea.DoBytes(ctx, "GET", apiPath, nil, nil, accept)
|
||||||
|
if err != nil {
|
||||||
|
return to.ErrorResult(fmt.Errorf("download archive (status %d): %w", statusCode, err))
|
||||||
|
}
|
||||||
|
|
||||||
|
// בדיקת גודל
|
||||||
|
if int64(len(archiveBytes)) > MaxArchiveSizeBytes {
|
||||||
|
return to.ErrorResult(fmt.Errorf(
|
||||||
|
"archive too large (%d bytes > %d limit). "+
|
||||||
|
"Use a more specific ref or fetch individual files via get_files_batch.",
|
||||||
|
len(archiveBytes), MaxArchiveSizeBytes,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
// כתיבה ל-disk
|
||||||
|
if err := os.WriteFile(outputPath, archiveBytes, 0o644); err != nil {
|
||||||
|
return to.ErrorResult(fmt.Errorf("write archive to %q: %w", outputPath, err))
|
||||||
|
}
|
||||||
|
|
||||||
|
// חישוב SHA-256 לוידוא integrity
|
||||||
|
hash := sha256.Sum256(archiveBytes)
|
||||||
|
sha := hex.EncodeToString(hash[:])
|
||||||
|
|
||||||
|
result := ArchiveResult{
|
||||||
|
SavedTo: outputPath,
|
||||||
|
SizeBytes: int64(len(archiveBytes)),
|
||||||
|
SHA256: sha,
|
||||||
|
Format: format,
|
||||||
|
Repository: fmt.Sprintf("%s/%s", owner, repo),
|
||||||
|
Ref: ref,
|
||||||
|
}
|
||||||
|
|
||||||
|
msg := fmt.Sprintf(
|
||||||
|
"Repository archive downloaded successfully:\n"+
|
||||||
|
" Repository: %s\n"+
|
||||||
|
" Ref: %s\n"+
|
||||||
|
" Format: %s\n"+
|
||||||
|
" Saved to: %s\n"+
|
||||||
|
" Size: %s\n"+
|
||||||
|
" SHA-256: %s\n\n"+
|
||||||
|
"Next steps: extract with %s\n",
|
||||||
|
result.Repository,
|
||||||
|
result.Ref,
|
||||||
|
result.Format,
|
||||||
|
result.SavedTo,
|
||||||
|
formatBytes(result.SizeBytes),
|
||||||
|
result.SHA256,
|
||||||
|
extractCommand(format, outputPath),
|
||||||
|
)
|
||||||
|
|
||||||
|
return to.TextResult(msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatBytes(b int64) string {
|
||||||
|
const unit = 1024
|
||||||
|
if b < unit {
|
||||||
|
return fmt.Sprintf("%d B", b)
|
||||||
|
}
|
||||||
|
div, exp := int64(unit), 0
|
||||||
|
for n := b / unit; n >= unit; n /= unit {
|
||||||
|
div *= unit
|
||||||
|
exp++
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%.1f %cB",
|
||||||
|
float64(b)/float64(div),
|
||||||
|
"KMGTPE"[exp],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractCommand(format, path string) string {
|
||||||
|
if strings.HasSuffix(format, "zip") {
|
||||||
|
return fmt.Sprintf("unzip %q -d <target_dir>", path)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("tar xzf %q -C <target_dir>", path)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user