From 8f4f8f3a1422ae0c0b89c8f8bbf1f4cc68fa2bc7 Mon Sep 17 00:00:00 2001 From: Chaim Marcus Date: Wed, 13 May 2026 16:19:52 +0000 Subject: [PATCH] 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) +}