forgejo-mcp/operation/release/release.go
Christoph Görn dd9116fbca
fix: 🚨 clear golangci-lint v2 backlog (closes bead forgejo-mcp-hc9)
- errorlint: fmt.Errorf %v→%w across all handlers (errors now wrap the
  underlying SDK/HTTP error for errors.Is/As); errors.Is for
  context.Canceled in cmd
- staticcheck quickfixes: Fprintf over WriteString(Sprintf), tagged
  switches, omit inferred 'any'; QF1001 resolved by hoisting hex checks
  into isHex predicates (normalizeColor, validateSHA)
- govet: reflect.Ptr → reflect.Pointer in pkg/ptr
- errcheck: explicit '_ =' for fs.Parse (ExitOnError) and deferred
  zap Sync
- repair four test assertions the autofix broke (it dropped the
  're.Code != X' half of combined type-assert checks, inverting the
  test); all errors.As sites use 'var re *resource.ResourceError'

golangci-lint: 0 issues. staticcheck, vet, tests: clean.
2026-06-11 00:03:36 +02:00

676 lines
24 KiB
Go

// Package release registers MCP tools for Forgejo releases and their
// attachments. The Forgejo SDK provides every endpoint we wrap, so this
// package uses forgejo.Client(ctx) directly (no raw HTTP fallback) — except
// for download_release_attachment, which fetches the browser_download_url
// through forgejo.DoRaw to share the inline-size cap with the issue/comment
// attachment download path.
package release
import (
"bytes"
"context"
"encoding/base64"
"errors"
"fmt"
"codeberg.org/goern/forgejo-mcp/v2/operation/params"
"codeberg.org/goern/forgejo-mcp/v2/pkg/forgejo"
"codeberg.org/goern/forgejo-mcp/v2/pkg/log"
"codeberg.org/goern/forgejo-mcp/v2/pkg/to"
forgejo_sdk "codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v3"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
)
const (
ListReleasesToolName = "list_releases"
GetReleaseByIDToolName = "get_release_by_id"
GetReleaseByTagToolName = "get_release_by_tag"
GetLatestReleaseToolName = "get_latest_release"
CreateReleaseToolName = "create_release"
EditReleaseToolName = "edit_release"
DeleteReleaseToolName = "delete_release"
DeleteReleaseByTagToolName = "delete_release_by_tag"
ListReleaseAttachmentsToolName = "list_release_attachments"
GetReleaseAttachmentToolName = "get_release_attachment"
DownloadReleaseAttachmentToolName = "download_release_attachment"
CreateReleaseAttachmentToolName = "create_release_attachment"
EditReleaseAttachmentToolName = "edit_release_attachment"
DeleteReleaseAttachmentToolName = "delete_release_attachment"
// State filter values for list_releases.
stateAll = "all"
stateDraft = "draft"
statePrerelease = "prerelease"
statePublished = "published"
)
// downloadResult is the metadata-only / over-cap shape returned by
// download_release_attachment. Mirrors operation/attachment.downloadResult
// so MCP clients see a consistent contract across attachment domains.
type downloadResult struct {
Attachment *forgejo_sdk.Attachment `json:"attachment"`
Inline bool `json:"inline"`
Reason string `json:"reason,omitempty"`
BytesIncluded int64 `json:"bytes_included,omitempty"`
}
var (
ListReleasesTool = mcp.NewTool(
ListReleasesToolName,
mcp.WithDescription("List releases for a repository. The state filter is applied client-side after pagination, so result size may be smaller than limit even when more matches exist on later pages."),
mcp.WithString("owner", mcp.Required(), mcp.Description(params.Owner)),
mcp.WithString("repo", mcp.Required(), mcp.Description(params.Repo)),
mcp.WithNumber("page", mcp.Description(params.Page), mcp.DefaultNumber(1)),
mcp.WithNumber("limit", mcp.Description(params.Limit), mcp.DefaultNumber(20)),
mcp.WithString("state", mcp.Description(params.ReleaseState), mcp.DefaultString(stateAll)),
)
GetReleaseByIDTool = mcp.NewTool(
GetReleaseByIDToolName,
mcp.WithDescription("Get a release by numeric ID."),
mcp.WithString("owner", mcp.Required(), mcp.Description(params.Owner)),
mcp.WithString("repo", mcp.Required(), mcp.Description(params.Repo)),
mcp.WithNumber("release_id", mcp.Required(), mcp.Description(params.ReleaseID)),
)
GetReleaseByTagTool = mcp.NewTool(
GetReleaseByTagToolName,
mcp.WithDescription("Get a release by tag name."),
mcp.WithString("owner", mcp.Required(), mcp.Description(params.Owner)),
mcp.WithString("repo", mcp.Required(), mcp.Description(params.Repo)),
mcp.WithString("tag", mcp.Required(), mcp.Description(params.ReleaseTag)),
)
GetLatestReleaseTool = mcp.NewTool(
GetLatestReleaseToolName,
mcp.WithDescription("Get the latest non-draft, non-prerelease release."),
mcp.WithString("owner", mcp.Required(), mcp.Description(params.Owner)),
mcp.WithString("repo", mcp.Required(), mcp.Description(params.Repo)),
)
CreateReleaseTool = mcp.NewTool(
CreateReleaseToolName,
mcp.WithDescription("Create a release. If the tag does not yet exist, pass target_commitish so Forgejo creates the tag."),
mcp.WithString("owner", mcp.Required(), mcp.Description(params.Owner)),
mcp.WithString("repo", mcp.Required(), mcp.Description(params.Repo)),
mcp.WithString("tag_name", mcp.Required(), mcp.Description(params.ReleaseTagName)),
mcp.WithString("target_commitish", mcp.Description(params.ReleaseTargetCommitish)),
mcp.WithString("name", mcp.Description(params.Title)),
mcp.WithString("body", mcp.Description(params.Body)),
mcp.WithBoolean("draft", mcp.Description(params.ReleaseDraft)),
mcp.WithBoolean("prerelease", mcp.Description(params.ReleasePrerelease)),
)
EditReleaseTool = mcp.NewTool(
EditReleaseToolName,
mcp.WithDescription("Edit an existing release. Only fields supplied by the caller are sent to Forgejo."),
mcp.WithString("owner", mcp.Required(), mcp.Description(params.Owner)),
mcp.WithString("repo", mcp.Required(), mcp.Description(params.Repo)),
mcp.WithNumber("release_id", mcp.Required(), mcp.Description(params.ReleaseID)),
mcp.WithString("tag_name", mcp.Description(params.ReleaseTagName)),
mcp.WithString("target_commitish", mcp.Description(params.ReleaseTargetCommitish)),
mcp.WithString("name", mcp.Description(params.Title)),
mcp.WithString("body", mcp.Description(params.Body)),
mcp.WithBoolean("draft", mcp.Description(params.ReleaseDraft)),
mcp.WithBoolean("prerelease", mcp.Description(params.ReleasePrerelease)),
)
DeleteReleaseTool = mcp.NewTool(
DeleteReleaseToolName,
mcp.WithDescription("Delete a release by numeric ID. Destructive."),
mcp.WithString("owner", mcp.Required(), mcp.Description(params.Owner)),
mcp.WithString("repo", mcp.Required(), mcp.Description(params.Repo)),
mcp.WithNumber("release_id", mcp.Required(), mcp.Description(params.ReleaseID)),
)
DeleteReleaseByTagTool = mcp.NewTool(
DeleteReleaseByTagToolName,
mcp.WithDescription("Delete a release by tag name. Destructive — verify tag before calling."),
mcp.WithString("owner", mcp.Required(), mcp.Description(params.Owner)),
mcp.WithString("repo", mcp.Required(), mcp.Description(params.Repo)),
mcp.WithString("tag", mcp.Required(), mcp.Description(params.ReleaseTag)),
)
ListReleaseAttachmentsTool = mcp.NewTool(
ListReleaseAttachmentsToolName,
mcp.WithDescription("List attachments on a release. The Forgejo API does not paginate this endpoint server-side, so the response is fetched in full and then sliced client-side; large attachment sets are still fully transferred from Forgejo before slicing."),
mcp.WithString("owner", mcp.Required(), mcp.Description(params.Owner)),
mcp.WithString("repo", mcp.Required(), mcp.Description(params.Repo)),
mcp.WithNumber("release_id", mcp.Required(), mcp.Description(params.ReleaseID)),
mcp.WithNumber("page", mcp.Description(params.Page), mcp.DefaultNumber(1)),
mcp.WithNumber("limit", mcp.Description(params.Limit), mcp.DefaultNumber(20)),
)
GetReleaseAttachmentTool = mcp.NewTool(
GetReleaseAttachmentToolName,
mcp.WithDescription("Get metadata for a single release attachment."),
mcp.WithString("owner", mcp.Required(), mcp.Description(params.Owner)),
mcp.WithString("repo", mcp.Required(), mcp.Description(params.Repo)),
mcp.WithNumber("release_id", mcp.Required(), mcp.Description(params.ReleaseID)),
mcp.WithNumber("attachment_id", mcp.Required(), mcp.Description(params.AttachmentID)),
)
DownloadReleaseAttachmentTool = mcp.NewTool(
DownloadReleaseAttachmentToolName,
mcp.WithDescription("Download a release attachment. Files at or above the inline cap return metadata + browser_download_url only; the caller is expected to fetch that URL with the same auth token."),
mcp.WithString("owner", mcp.Required(), mcp.Description(params.Owner)),
mcp.WithString("repo", mcp.Required(), mcp.Description(params.Repo)),
mcp.WithNumber("release_id", mcp.Required(), mcp.Description(params.ReleaseID)),
mcp.WithNumber("attachment_id", mcp.Required(), mcp.Description(params.AttachmentID)),
)
CreateReleaseAttachmentTool = mcp.NewTool(
CreateReleaseAttachmentToolName,
mcp.WithDescription("Upload an attachment to a release."),
mcp.WithString("owner", mcp.Required(), mcp.Description(params.Owner)),
mcp.WithString("repo", mcp.Required(), mcp.Description(params.Repo)),
mcp.WithNumber("release_id", mcp.Required(), mcp.Description(params.ReleaseID)),
mcp.WithString("content", mcp.Required(), mcp.Description(params.AttachmentContent)),
mcp.WithString("filename", mcp.Required(), mcp.Description(params.AttachmentFilename)),
mcp.WithString("mime_type", mcp.Description(params.AttachmentMIME)),
)
EditReleaseAttachmentTool = mcp.NewTool(
EditReleaseAttachmentToolName,
mcp.WithDescription("Rename a release attachment."),
mcp.WithString("owner", mcp.Required(), mcp.Description(params.Owner)),
mcp.WithString("repo", mcp.Required(), mcp.Description(params.Repo)),
mcp.WithNumber("release_id", mcp.Required(), mcp.Description(params.ReleaseID)),
mcp.WithNumber("attachment_id", mcp.Required(), mcp.Description(params.AttachmentID)),
mcp.WithString("name", mcp.Required(), mcp.Description(params.AttachmentName)),
)
DeleteReleaseAttachmentTool = mcp.NewTool(
DeleteReleaseAttachmentToolName,
mcp.WithDescription("Delete a release attachment. Destructive."),
mcp.WithString("owner", mcp.Required(), mcp.Description(params.Owner)),
mcp.WithString("repo", mcp.Required(), mcp.Description(params.Repo)),
mcp.WithNumber("release_id", mcp.Required(), mcp.Description(params.ReleaseID)),
mcp.WithNumber("attachment_id", mcp.Required(), mcp.Description(params.AttachmentID)),
)
)
// RegisterTool registers all 14 release tools with the MCP server.
func RegisterTool(s *server.MCPServer) {
s.AddTool(ListReleasesTool, ListReleasesFn)
s.AddTool(GetReleaseByIDTool, GetReleaseByIDFn)
s.AddTool(GetReleaseByTagTool, GetReleaseByTagFn)
s.AddTool(GetLatestReleaseTool, GetLatestReleaseFn)
s.AddTool(CreateReleaseTool, CreateReleaseFn)
s.AddTool(EditReleaseTool, EditReleaseFn)
s.AddTool(DeleteReleaseTool, DeleteReleaseFn)
s.AddTool(DeleteReleaseByTagTool, DeleteReleaseByTagFn)
s.AddTool(ListReleaseAttachmentsTool, ListReleaseAttachmentsFn)
s.AddTool(GetReleaseAttachmentTool, GetReleaseAttachmentFn)
s.AddTool(DownloadReleaseAttachmentTool, DownloadReleaseAttachmentFn)
s.AddTool(CreateReleaseAttachmentTool, CreateReleaseAttachmentFn)
s.AddTool(EditReleaseAttachmentTool, EditReleaseAttachmentFn)
s.AddTool(DeleteReleaseAttachmentTool, DeleteReleaseAttachmentFn)
}
// --- Release tools ----------------------------------------------------------
func ListReleasesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called ListReleasesFn")
args := req.GetArguments()
owner, _ := args["owner"].(string)
repo, _ := args["repo"].(string)
page, _ := to.Float64(args["page"])
if page == 0 {
page = 1
}
limit, _ := to.Float64(args["limit"])
if limit == 0 {
limit = 20
}
state, ok := args["state"].(string)
if !ok || state == "" {
state = stateAll
}
switch state {
case stateAll, stateDraft, statePrerelease, statePublished:
default:
return to.ErrorResult(fmt.Errorf("invalid state %q: must be one of all|draft|prerelease|published", state))
}
opt := forgejo_sdk.ListReleasesOptions{
ListOptions: forgejo_sdk.ListOptions{
Page: int(page),
PageSize: int(limit),
},
}
client, err := forgejo.Client(ctx)
if err != nil {
return to.ErrorResult(err)
}
rels, _, err := client.ListReleases(owner, repo, opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("list releases err: %w", err))
}
filtered := filterReleasesByState(rels, state)
if filtered == nil {
filtered = []*forgejo_sdk.Release{}
}
return to.TextResult(filtered)
}
func filterReleasesByState(rels []*forgejo_sdk.Release, state string) []*forgejo_sdk.Release {
if state == stateAll {
return rels
}
out := make([]*forgejo_sdk.Release, 0, len(rels))
for _, r := range rels {
if r == nil {
continue
}
switch state {
case stateDraft:
if r.IsDraft {
out = append(out, r)
}
case statePrerelease:
if !r.IsDraft && r.IsPrerelease {
out = append(out, r)
}
case statePublished:
if !r.IsDraft && !r.IsPrerelease {
out = append(out, r)
}
}
}
return out
}
func GetReleaseByIDFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called GetReleaseByIDFn")
args := req.GetArguments()
owner, _ := args["owner"].(string)
repo, _ := args["repo"].(string)
rid, err := to.Float64(args["release_id"])
if err != nil {
return to.ErrorResult(fmt.Errorf("release_id: %w", err))
}
client, err := forgejo.Client(ctx)
if err != nil {
return to.ErrorResult(err)
}
rel, _, err := client.GetRelease(owner, repo, int64(rid))
if err != nil {
return to.ErrorResult(fmt.Errorf("get release err: %w", err))
}
return to.TextResult(rel)
}
func GetReleaseByTagFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called GetReleaseByTagFn")
args := req.GetArguments()
owner, _ := args["owner"].(string)
repo, _ := args["repo"].(string)
tag, _ := args["tag"].(string)
if tag == "" {
return to.ErrorResult(fmt.Errorf("tag is required"))
}
client, err := forgejo.Client(ctx)
if err != nil {
return to.ErrorResult(err)
}
rel, _, err := client.GetReleaseByTag(owner, repo, tag)
if err != nil {
return to.ErrorResult(fmt.Errorf("get release by tag err: %w", err))
}
return to.TextResult(rel)
}
func GetLatestReleaseFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called GetLatestReleaseFn")
args := req.GetArguments()
owner, _ := args["owner"].(string)
repo, _ := args["repo"].(string)
client, err := forgejo.Client(ctx)
if err != nil {
return to.ErrorResult(err)
}
rel, _, err := client.GetLatestRelease(owner, repo)
if err != nil {
return to.ErrorResult(fmt.Errorf("get latest release err: %w", err))
}
return to.TextResult(rel)
}
func CreateReleaseFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called CreateReleaseFn")
args := req.GetArguments()
owner, _ := args["owner"].(string)
repo, _ := args["repo"].(string)
tagName, _ := args["tag_name"].(string)
if tagName == "" {
return to.ErrorResult(fmt.Errorf("tag_name is required"))
}
target, _ := args["target_commitish"].(string)
name, _ := args["name"].(string)
body, _ := args["body"].(string)
draft, _ := args["draft"].(bool)
prerelease, _ := args["prerelease"].(bool)
// SDK's CreateReleaseOption.Validate rejects empty Title. Default it to
// tag_name so callers can omit name without tripping the SDK's check.
if name == "" {
name = tagName
}
opt := forgejo_sdk.CreateReleaseOption{
TagName: tagName,
Target: target,
Title: name,
Note: body,
IsDraft: draft,
IsPrerelease: prerelease,
}
client, err := forgejo.Client(ctx)
if err != nil {
return to.ErrorResult(err)
}
rel, _, err := client.CreateRelease(owner, repo, opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("create release err: %w", err))
}
return to.TextResult(rel)
}
func EditReleaseFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called EditReleaseFn")
args := req.GetArguments()
owner, _ := args["owner"].(string)
repo, _ := args["repo"].(string)
rid, err := to.Float64(args["release_id"])
if err != nil {
return to.ErrorResult(fmt.Errorf("release_id: %w", err))
}
opt := forgejo_sdk.EditReleaseOption{}
if v, ok := args["tag_name"].(string); ok {
opt.TagName = v
}
if v, ok := args["target_commitish"].(string); ok {
opt.Target = v
}
if v, ok := args["name"].(string); ok {
opt.Title = v
}
if v, ok := args["body"].(string); ok {
opt.Note = v
}
if v, ok := args["draft"].(bool); ok {
opt.IsDraft = &v
}
if v, ok := args["prerelease"].(bool); ok {
opt.IsPrerelease = &v
}
client, err := forgejo.Client(ctx)
if err != nil {
return to.ErrorResult(err)
}
rel, _, err := client.EditRelease(owner, repo, int64(rid), opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("edit release err: %w", err))
}
return to.TextResult(rel)
}
func DeleteReleaseFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called DeleteReleaseFn")
args := req.GetArguments()
owner, _ := args["owner"].(string)
repo, _ := args["repo"].(string)
rid, err := to.Float64(args["release_id"])
if err != nil {
return to.ErrorResult(fmt.Errorf("release_id: %w", err))
}
client, err := forgejo.Client(ctx)
if err != nil {
return to.ErrorResult(err)
}
if _, err = client.DeleteRelease(owner, repo, int64(rid)); err != nil {
return to.ErrorResult(fmt.Errorf("delete release err: %w", err))
}
return to.TextResult(map[string]string{"status": "deleted"})
}
func DeleteReleaseByTagFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called DeleteReleaseByTagFn")
args := req.GetArguments()
owner, _ := args["owner"].(string)
repo, _ := args["repo"].(string)
tag, _ := args["tag"].(string)
if tag == "" {
return to.ErrorResult(fmt.Errorf("tag is required"))
}
client, err := forgejo.Client(ctx)
if err != nil {
return to.ErrorResult(err)
}
if _, err = client.DeleteReleaseByTag(owner, repo, tag); err != nil {
return to.ErrorResult(fmt.Errorf("delete release by tag err: %w", err))
}
return to.TextResult(map[string]string{"status": "deleted"})
}
// --- Release attachment tools ----------------------------------------------
func ListReleaseAttachmentsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called ListReleaseAttachmentsFn")
args := req.GetArguments()
owner, _ := args["owner"].(string)
repo, _ := args["repo"].(string)
rid, err := to.Float64(args["release_id"])
if err != nil {
return to.ErrorResult(fmt.Errorf("release_id: %w", err))
}
page, _ := to.Float64(args["page"])
if page == 0 {
page = 1
}
limit, _ := to.Float64(args["limit"])
if limit == 0 {
limit = 20
}
client, err := forgejo.Client(ctx)
if err != nil {
return to.ErrorResult(err)
}
all, _, err := client.ListReleaseAttachments(owner, repo, int64(rid))
if err != nil {
return to.ErrorResult(fmt.Errorf("list release attachments err: %w", err))
}
sliced := sliceAttachments(all, int(page), int(limit))
return to.TextResult(sliced)
}
func sliceAttachments(all []*forgejo_sdk.Attachment, page, limit int) []*forgejo_sdk.Attachment {
if page < 1 {
page = 1
}
if limit < 1 {
limit = 20
}
offset := (page - 1) * limit
if offset >= len(all) {
return []*forgejo_sdk.Attachment{}
}
end := offset + limit
if end > len(all) {
end = len(all)
}
return all[offset:end]
}
func GetReleaseAttachmentFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called GetReleaseAttachmentFn")
args := req.GetArguments()
owner, _ := args["owner"].(string)
repo, _ := args["repo"].(string)
rid, err := to.Float64(args["release_id"])
if err != nil {
return to.ErrorResult(fmt.Errorf("release_id: %w", err))
}
aid, err := to.Float64(args["attachment_id"])
if err != nil {
return to.ErrorResult(fmt.Errorf("attachment_id: %w", err))
}
client, err := forgejo.Client(ctx)
if err != nil {
return to.ErrorResult(err)
}
att, _, err := client.GetReleaseAttachment(owner, repo, int64(rid), int64(aid))
if err != nil {
return to.ErrorResult(fmt.Errorf("get release attachment err: %w", err))
}
return to.TextResult(att)
}
func DownloadReleaseAttachmentFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called DownloadReleaseAttachmentFn")
args := req.GetArguments()
owner, _ := args["owner"].(string)
repo, _ := args["repo"].(string)
rid, err := to.Float64(args["release_id"])
if err != nil {
return to.ErrorResult(fmt.Errorf("release_id: %w", err))
}
aid, err := to.Float64(args["attachment_id"])
if err != nil {
return to.ErrorResult(fmt.Errorf("attachment_id: %w", err))
}
client, err := forgejo.Client(ctx)
if err != nil {
return to.ErrorResult(err)
}
att, _, err := client.GetReleaseAttachment(owner, repo, int64(rid), int64(aid))
if err != nil {
return to.ErrorResult(fmt.Errorf("download release attachment (metadata) err: %w", err))
}
return downloadResultFor(ctx, att)
}
func CreateReleaseAttachmentFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called CreateReleaseAttachmentFn")
args := req.GetArguments()
owner, _ := args["owner"].(string)
repo, _ := args["repo"].(string)
rid, err := to.Float64(args["release_id"])
if err != nil {
return to.ErrorResult(fmt.Errorf("release_id: %w", err))
}
content, _ := args["content"].(string)
filename, _ := args["filename"].(string)
// mime_type is accepted for parity with issue/comment attachments, but
// the SDK's CreateReleaseAttachment derives content type from the
// filename and does not forward an explicit hint.
_, _ = args["mime_type"].(string)
if filename == "" {
return to.ErrorResult(fmt.Errorf("filename is required"))
}
raw, err := base64.StdEncoding.DecodeString(content)
if err != nil {
return to.ErrorResult(fmt.Errorf("content must be base64-encoded: %w", err))
}
client, err := forgejo.Client(ctx)
if err != nil {
return to.ErrorResult(err)
}
att, _, err := client.CreateReleaseAttachment(owner, repo, int64(rid), bytes.NewReader(raw), filename)
if err != nil {
return to.ErrorResult(fmt.Errorf("create release attachment err: %w", err))
}
return to.TextResult(att)
}
func EditReleaseAttachmentFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called EditReleaseAttachmentFn")
args := req.GetArguments()
owner, _ := args["owner"].(string)
repo, _ := args["repo"].(string)
rid, err := to.Float64(args["release_id"])
if err != nil {
return to.ErrorResult(fmt.Errorf("release_id: %w", err))
}
aid, err := to.Float64(args["attachment_id"])
if err != nil {
return to.ErrorResult(fmt.Errorf("attachment_id: %w", err))
}
name, _ := args["name"].(string)
if name == "" {
return to.ErrorResult(fmt.Errorf("name is required"))
}
client, err := forgejo.Client(ctx)
if err != nil {
return to.ErrorResult(err)
}
att, _, err := client.EditReleaseAttachment(owner, repo, int64(rid), int64(aid), forgejo_sdk.EditAttachmentOptions{Name: name})
if err != nil {
return to.ErrorResult(fmt.Errorf("edit release attachment err: %w", err))
}
return to.TextResult(att)
}
func DeleteReleaseAttachmentFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called DeleteReleaseAttachmentFn")
args := req.GetArguments()
owner, _ := args["owner"].(string)
repo, _ := args["repo"].(string)
rid, err := to.Float64(args["release_id"])
if err != nil {
return to.ErrorResult(fmt.Errorf("release_id: %w", err))
}
aid, err := to.Float64(args["attachment_id"])
if err != nil {
return to.ErrorResult(fmt.Errorf("attachment_id: %w", err))
}
client, err := forgejo.Client(ctx)
if err != nil {
return to.ErrorResult(err)
}
if _, err = client.DeleteReleaseAttachment(owner, repo, int64(rid), int64(aid)); err != nil {
return to.ErrorResult(fmt.Errorf("delete release attachment err: %w", err))
}
return to.TextResult(map[string]string{"status": "deleted"})
}
// downloadResultFor mirrors operation/attachment.downloadResultFor: inline
// bytes when under the cap; metadata + browser_download_url otherwise.
func downloadResultFor(ctx context.Context, att *forgejo_sdk.Attachment) (*mcp.CallToolResult, error) {
res := &downloadResult{Attachment: att}
if att.Size >= forgejo.MaxInlineDownloadBytes {
res.Inline = false
res.Reason = fmt.Sprintf("size %d bytes >= inline cap %d; fetch browser_download_url with Authorization: token <TOKEN>", att.Size, forgejo.MaxInlineDownloadBytes)
return to.TextResult(res)
}
body, ct, err := forgejo.DoRaw(ctx, att.DownloadURL)
if err != nil {
if errors.Is(err, forgejo.ErrPayloadTooLarge) {
res.Inline = false
res.Reason = "body exceeded inline cap during fetch; fetch browser_download_url with Authorization: token <TOKEN>"
return to.TextResult(res)
}
return to.ErrorResult(fmt.Errorf("download body err: %w", err))
}
res.Inline = true
res.BytesIncluded = int64(len(body))
textPart := to.SafeJSONMarshal(res)
return mcp.NewToolResultResource(textPart, mcp.BlobResourceContents{
URI: att.DownloadURL,
MIMEType: ct,
Blob: base64.StdEncoding.EncodeToString(body),
}), nil
}