From 4131a99256614cd4550720eb9f8dd72317fa43e4 Mon Sep 17 00:00:00 2001 From: Chaim Marcus Date: Wed, 13 May 2026 16:18:42 +0000 Subject: [PATCH 1/3] feat(repo): add download_repo_archive tool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- operation/repo/archive.go | 201 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 201 insertions(+) create mode 100644 operation/repo/archive.go diff --git a/operation/repo/archive.go b/operation/repo/archive.go new file mode 100644 index 0000000..0bd0f84 --- /dev/null +++ b/operation/repo/archive.go @@ -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 ", path) + } + return fmt.Sprintf("tar xzf %q -C ", path) +} -- 2.52.0 From d0f9de5923a5f59d9a9a7b69220fd366d15de946 Mon Sep 17 00:00:00 2001 From: Chaim Marcus Date: Wed, 13 May 2026 16:19:24 +0000 Subject: [PATCH 2/3] 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. --- operation/repo/file_batch.go | 361 +++++++++++++++++++++++++++++++++++ 1 file changed, 361 insertions(+) create mode 100644 operation/repo/file_batch.go diff --git a/operation/repo/file_batch.go b/operation/repo/file_batch.go new file mode 100644 index 0000000..62048bf --- /dev/null +++ b/operation/repo/file_batch.go @@ -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)) +} -- 2.52.0 From 8f4f8f3a1422ae0c0b89c8f8bbf1f4cc68fa2bc7 Mon Sep 17 00:00:00 2001 From: Chaim Marcus Date: Wed, 13 May 2026 16:19:52 +0000 Subject: [PATCH 3/3] feat(repo): add upload_release_attachment tool Upload a file as an asset to an existing release. Completes the publishing workflow for extension .zip packages. Uses multipart form POST to Gitea's release assets endpoint. Safety: 100MB cap, requires absolute file_path, supports custom asset name (defaults to filename basename). Closes the gap in the release workflow: 1. create_release (existing) - creates the release 2. upload_release_attachment (NEW) - uploads the .zip/binary asset --- operation/repo/release_asset.go | 191 ++++++++++++++++++++++++++++++++ 1 file changed, 191 insertions(+) create mode 100644 operation/repo/release_asset.go diff --git a/operation/repo/release_asset.go b/operation/repo/release_asset.go new file mode 100644 index 0000000..a1e929b --- /dev/null +++ b/operation/repo/release_asset.go @@ -0,0 +1,191 @@ +// release_asset.go - upload_release_attachment +// +// מטרה: השלמת workflow הפצה - העלאת קובץ ZIP של extension ל-release ב-Gitea. + +package repo + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "mime/multipart" + "net/http" + "net/url" + "os" + "path/filepath" + + "gitea.com/gitea/gitea-mcp/pkg/flag" + "gitea.com/gitea/gitea-mcp/pkg/log" + "gitea.com/gitea/gitea-mcp/pkg/params" + "gitea.com/gitea/gitea-mcp/pkg/to" + + mcpContext "gitea.com/gitea/gitea-mcp/pkg/context" + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" +) + +const ( + UploadReleaseAttachmentToolName = "upload_release_attachment" + + // MaxAttachmentSizeBytes — תקרה לגודל attachment (100MB). + MaxAttachmentSizeBytes = 100 * 1024 * 1024 +) + +var UploadReleaseAttachmentTool = mcp.NewTool( + UploadReleaseAttachmentToolName, + mcp.WithDescription( + "Upload a file as an asset to an existing release. "+ + "Essential for publishing extension .zip packages. "+ + "The file is read from the local filesystem and uploaded via multipart form.", + ), + mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")), + mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")), + mcp.WithNumber("release_id", mcp.Required(), mcp.Description("release ID")), + mcp.WithString("file_path", mcp.Required(), + mcp.Description("absolute path to the local file to upload")), + mcp.WithString("name", + mcp.Description("filename in the release (defaults to basename of file_path)")), +) + +func init() { + Tool.RegisterWrite(server.ServerTool{ + Tool: UploadReleaseAttachmentTool, + Handler: UploadReleaseAttachmentFn, + }) +} + +// ReleaseAsset — תוצאה מ-Gitea API. +type ReleaseAsset struct { + ID int64 `json:"id"` + Name string `json:"name"` + Size int64 `json:"size"` + DownloadCount int64 `json:"download_count"` + BrowserDownloadURL string `json:"browser_download_url"` + CreatedAt string `json:"created_at"` +} + +func UploadReleaseAttachmentFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + log.Debugf("Called UploadReleaseAttachmentFn") + 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) + } + releaseID, err := params.GetIndex(args, "release_id") + if err != nil { + return to.ErrorResult(err) + } + filePath, err := params.GetString(args, "file_path") + if err != nil { + return to.ErrorResult(err) + } + name, _ := args["name"].(string) + + // אבטחה: דרוש absolute path + if !filepath.IsAbs(filePath) { + return to.ErrorResult(fmt.Errorf("file_path must be absolute")) + } + + fileInfo, err := os.Stat(filePath) + if err != nil { + return to.ErrorResult(fmt.Errorf("stat file: %w", err)) + } + if fileInfo.Size() > MaxAttachmentSizeBytes { + return to.ErrorResult(fmt.Errorf( + "file too large (%d bytes > max %d)", + fileInfo.Size(), MaxAttachmentSizeBytes, + )) + } + if name == "" { + name = filepath.Base(filePath) + } + + file, err := os.Open(filePath) + if err != nil { + return to.ErrorResult(fmt.Errorf("open file: %w", err)) + } + defer file.Close() + + // בניית multipart form + var body bytes.Buffer + writer := multipart.NewWriter(&body) + part, err := writer.CreateFormFile("attachment", name) + if err != nil { + return to.ErrorResult(fmt.Errorf("create form file: %w", err)) + } + if _, err := io.Copy(part, file); err != nil { + return to.ErrorResult(fmt.Errorf("copy file content: %w", err)) + } + if err := writer.Close(); err != nil { + return to.ErrorResult(fmt.Errorf("close writer: %w", err)) + } + + host := flag.Host + if host == "" { + return to.ErrorResult(fmt.Errorf("gitea host not configured")) + } + apiURL := fmt.Sprintf("%s/api/v1/repos/%s/%s/releases/%d/assets", + host, + url.PathEscape(owner), + url.PathEscape(repo), + releaseID, + ) + + httpReq, err := http.NewRequestWithContext(ctx, "POST", apiURL, &body) + if err != nil { + return to.ErrorResult(fmt.Errorf("create request: %w", err)) + } + + // אימות - token מ-context או flag + token := flag.Token + if t, ok := ctx.Value(mcpContext.TokenContextKey).(string); ok && t != "" { + token = t + } + if token != "" { + httpReq.Header.Set("Authorization", "token "+token) + } + httpReq.Header.Set("Content-Type", writer.FormDataContentType()) + httpReq.Header.Set("Accept", "application/json") + + client := &http.Client{} + resp, err := client.Do(httpReq) + if err != nil { + return to.ErrorResult(fmt.Errorf("upload request: %w", err)) + } + defer resp.Body.Close() + + respBody, _ := io.ReadAll(resp.Body) + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return to.ErrorResult(fmt.Errorf( + "upload failed (status %d): %s", + resp.StatusCode, string(respBody), + )) + } + + var asset ReleaseAsset + if err := json.Unmarshal(respBody, &asset); err != nil { + return to.ErrorResult(fmt.Errorf("parse response: %w", err)) + } + + msg := fmt.Sprintf( + "Asset uploaded successfully:\n"+ + " Asset ID: %d\n"+ + " Name: %s\n"+ + " Size: %s\n"+ + " Download URL: %s\n"+ + " Created at: %s\n", + asset.ID, + asset.Name, + formatBytes(asset.Size), + asset.BrowserDownloadURL, + asset.CreatedAt, + ) + return to.TextResult(msg) +} -- 2.52.0