// 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 ", path) } return fmt.Sprintf("tar xzf %q -C ", path) }