// 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) }