feat: add bulk operations - 4 new MCP tools for repo/file management #1

Open
chaim wants to merge 3 commits from feat/bulk-operations into main
Showing only changes of commit 8f4f8f3a14 - Show all commits
+191
View File
@@ -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)
}