forgejo-mcp/operation/attachment/attachment.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

512 lines
22 KiB
Go

// Package attachment registers MCP tools for issue and issue-comment
// attachments. It uses pkg/forgejo's raw-HTTP helper because forgejo-sdk/v3
// has no methods for these endpoints; see docs/plans/issue-attachments.md.
package attachment
import (
"bytes"
"context"
"encoding/base64"
"errors"
"fmt"
"net/http"
"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 (
// Issue-scoped tool names
ListIssueAttachmentsToolName = "list_issue_attachments"
GetIssueAttachmentToolName = "get_issue_attachment"
DownloadIssueAttachmentToolName = "download_issue_attachment"
CreateIssueAttachmentToolName = "create_issue_attachment"
EditIssueAttachmentToolName = "edit_issue_attachment"
DeleteIssueAttachmentToolName = "delete_issue_attachment"
// Comment-scoped tool names
ListCommentAttachmentsToolName = "list_comment_attachments"
GetCommentAttachmentToolName = "get_comment_attachment"
DownloadCommentAttachmentToolName = "download_comment_attachment"
CreateCommentAttachmentToolName = "create_comment_attachment"
EditCommentAttachmentToolName = "edit_comment_attachment"
DeleteCommentAttachmentToolName = "delete_comment_attachment"
multipartFieldName = "attachment"
)
// downloadResult is the shape returned by download_*_attachment when bytes
// are inlined. The Blob field carries the embedded resource separately;
// this struct is only used for the metadata-only path.
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 (
ListIssueAttachmentsTool = mcp.NewTool(
ListIssueAttachmentsToolName,
mcp.WithDescription("List attachments on an issue or pull request."),
mcp.WithString("owner", mcp.Required(), mcp.Description(params.Owner)),
mcp.WithString("repo", mcp.Required(), mcp.Description(params.Repo)),
mcp.WithNumber("index", mcp.Required(), mcp.Description(params.Index)),
)
GetIssueAttachmentTool = mcp.NewTool(
GetIssueAttachmentToolName,
mcp.WithDescription("Get metadata for a single issue/PR attachment."),
mcp.WithString("owner", mcp.Required(), mcp.Description(params.Owner)),
mcp.WithString("repo", mcp.Required(), mcp.Description(params.Repo)),
mcp.WithNumber("index", mcp.Required(), mcp.Description(params.Index)),
mcp.WithNumber("attachment_id", mcp.Required(), mcp.Description(params.AttachmentID)),
)
DownloadIssueAttachmentTool = mcp.NewTool(
DownloadIssueAttachmentToolName,
mcp.WithDescription("Download an issue/PR 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("index", mcp.Required(), mcp.Description(params.Index)),
mcp.WithNumber("attachment_id", mcp.Required(), mcp.Description(params.AttachmentID)),
)
CreateIssueAttachmentTool = mcp.NewTool(
CreateIssueAttachmentToolName,
mcp.WithDescription("Upload a new attachment to an issue or pull request."),
mcp.WithString("owner", mcp.Required(), mcp.Description(params.Owner)),
mcp.WithString("repo", mcp.Required(), mcp.Description(params.Repo)),
mcp.WithNumber("index", mcp.Required(), mcp.Description(params.Index)),
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)),
)
EditIssueAttachmentTool = mcp.NewTool(
EditIssueAttachmentToolName,
mcp.WithDescription("Rename an issue/PR attachment."),
mcp.WithString("owner", mcp.Required(), mcp.Description(params.Owner)),
mcp.WithString("repo", mcp.Required(), mcp.Description(params.Repo)),
mcp.WithNumber("index", mcp.Required(), mcp.Description(params.Index)),
mcp.WithNumber("attachment_id", mcp.Required(), mcp.Description(params.AttachmentID)),
mcp.WithString("name", mcp.Required(), mcp.Description(params.AttachmentName)),
)
DeleteIssueAttachmentTool = mcp.NewTool(
DeleteIssueAttachmentToolName,
mcp.WithDescription("Delete an issue/PR attachment."),
mcp.WithString("owner", mcp.Required(), mcp.Description(params.Owner)),
mcp.WithString("repo", mcp.Required(), mcp.Description(params.Repo)),
mcp.WithNumber("index", mcp.Required(), mcp.Description(params.Index)),
mcp.WithNumber("attachment_id", mcp.Required(), mcp.Description(params.AttachmentID)),
)
ListCommentAttachmentsTool = mcp.NewTool(
ListCommentAttachmentsToolName,
mcp.WithDescription("List attachments on an issue/PR comment."),
mcp.WithString("owner", mcp.Required(), mcp.Description(params.Owner)),
mcp.WithString("repo", mcp.Required(), mcp.Description(params.Repo)),
mcp.WithNumber("comment_id", mcp.Required(), mcp.Description(params.CommentID)),
)
GetCommentAttachmentTool = mcp.NewTool(
GetCommentAttachmentToolName,
mcp.WithDescription("Get metadata for a single comment attachment."),
mcp.WithString("owner", mcp.Required(), mcp.Description(params.Owner)),
mcp.WithString("repo", mcp.Required(), mcp.Description(params.Repo)),
mcp.WithNumber("comment_id", mcp.Required(), mcp.Description(params.CommentID)),
mcp.WithNumber("attachment_id", mcp.Required(), mcp.Description(params.AttachmentID)),
)
DownloadCommentAttachmentTool = mcp.NewTool(
DownloadCommentAttachmentToolName,
mcp.WithDescription("Download a comment 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("comment_id", mcp.Required(), mcp.Description(params.CommentID)),
mcp.WithNumber("attachment_id", mcp.Required(), mcp.Description(params.AttachmentID)),
)
CreateCommentAttachmentTool = mcp.NewTool(
CreateCommentAttachmentToolName,
mcp.WithDescription("Upload a new attachment to an issue/PR comment."),
mcp.WithString("owner", mcp.Required(), mcp.Description(params.Owner)),
mcp.WithString("repo", mcp.Required(), mcp.Description(params.Repo)),
mcp.WithNumber("comment_id", mcp.Required(), mcp.Description(params.CommentID)),
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)),
)
EditCommentAttachmentTool = mcp.NewTool(
EditCommentAttachmentToolName,
mcp.WithDescription("Rename a comment attachment."),
mcp.WithString("owner", mcp.Required(), mcp.Description(params.Owner)),
mcp.WithString("repo", mcp.Required(), mcp.Description(params.Repo)),
mcp.WithNumber("comment_id", mcp.Required(), mcp.Description(params.CommentID)),
mcp.WithNumber("attachment_id", mcp.Required(), mcp.Description(params.AttachmentID)),
mcp.WithString("name", mcp.Required(), mcp.Description(params.AttachmentName)),
)
DeleteCommentAttachmentTool = mcp.NewTool(
DeleteCommentAttachmentToolName,
mcp.WithDescription("Delete a comment attachment."),
mcp.WithString("owner", mcp.Required(), mcp.Description(params.Owner)),
mcp.WithString("repo", mcp.Required(), mcp.Description(params.Repo)),
mcp.WithNumber("comment_id", mcp.Required(), mcp.Description(params.CommentID)),
mcp.WithNumber("attachment_id", mcp.Required(), mcp.Description(params.AttachmentID)),
)
)
// RegisterTool registers all 12 attachment tools with the MCP server.
func RegisterTool(s *server.MCPServer) {
s.AddTool(ListIssueAttachmentsTool, ListIssueAttachmentsFn)
s.AddTool(GetIssueAttachmentTool, GetIssueAttachmentFn)
s.AddTool(DownloadIssueAttachmentTool, DownloadIssueAttachmentFn)
s.AddTool(CreateIssueAttachmentTool, CreateIssueAttachmentFn)
s.AddTool(EditIssueAttachmentTool, EditIssueAttachmentFn)
s.AddTool(DeleteIssueAttachmentTool, DeleteIssueAttachmentFn)
s.AddTool(ListCommentAttachmentsTool, ListCommentAttachmentsFn)
s.AddTool(GetCommentAttachmentTool, GetCommentAttachmentFn)
s.AddTool(DownloadCommentAttachmentTool, DownloadCommentAttachmentFn)
s.AddTool(CreateCommentAttachmentTool, CreateCommentAttachmentFn)
s.AddTool(EditCommentAttachmentTool, EditCommentAttachmentFn)
s.AddTool(DeleteCommentAttachmentTool, DeleteCommentAttachmentFn)
}
// --- Issue-scoped handlers --------------------------------------------------
func ListIssueAttachmentsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called ListIssueAttachmentsFn")
owner, _ := req.GetArguments()["owner"].(string)
repo, _ := req.GetArguments()["repo"].(string)
index, err := to.Float64(req.GetArguments()["index"])
if err != nil {
return to.ErrorResult(fmt.Errorf("index: %w", err))
}
var out []*forgejo_sdk.Attachment
path := fmt.Sprintf("/repos/%s/%s/issues/%d/assets", owner, repo, int64(index))
if err := forgejo.DoJSONList(ctx, http.MethodGet, path, &out); err != nil {
return to.ErrorResult(fmt.Errorf("list issue attachments err: %w", err))
}
if out == nil {
out = []*forgejo_sdk.Attachment{}
}
return to.TextResult(out)
}
func GetIssueAttachmentFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called GetIssueAttachmentFn")
owner, _ := req.GetArguments()["owner"].(string)
repo, _ := req.GetArguments()["repo"].(string)
index, err := to.Float64(req.GetArguments()["index"])
if err != nil {
return to.ErrorResult(fmt.Errorf("index: %w", err))
}
aid, err := to.Float64(req.GetArguments()["attachment_id"])
if err != nil {
return to.ErrorResult(fmt.Errorf("attachment_id: %w", err))
}
att, err := getIssueAttachment(ctx, owner, repo, int64(index), int64(aid))
if err != nil {
return to.ErrorResult(fmt.Errorf("get issue attachment err: %w", err))
}
return to.TextResult(att)
}
func DownloadIssueAttachmentFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called DownloadIssueAttachmentFn")
owner, _ := req.GetArguments()["owner"].(string)
repo, _ := req.GetArguments()["repo"].(string)
index, err := to.Float64(req.GetArguments()["index"])
if err != nil {
return to.ErrorResult(fmt.Errorf("index: %w", err))
}
aid, err := to.Float64(req.GetArguments()["attachment_id"])
if err != nil {
return to.ErrorResult(fmt.Errorf("attachment_id: %w", err))
}
att, err := getIssueAttachment(ctx, owner, repo, int64(index), int64(aid))
if err != nil {
return to.ErrorResult(fmt.Errorf("download issue attachment (metadata) err: %w", err))
}
return downloadResultFor(ctx, att)
}
func CreateIssueAttachmentFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called CreateIssueAttachmentFn")
owner, _ := req.GetArguments()["owner"].(string)
repo, _ := req.GetArguments()["repo"].(string)
index, err := to.Float64(req.GetArguments()["index"])
if err != nil {
return to.ErrorResult(fmt.Errorf("index: %w", err))
}
content, _ := req.GetArguments()["content"].(string)
filename, _ := req.GetArguments()["filename"].(string)
mimeType, _ := req.GetArguments()["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))
}
var att forgejo_sdk.Attachment
path := fmt.Sprintf("/repos/%s/%s/issues/%d/assets", owner, repo, int64(index))
if err := forgejo.DoMultipart(ctx, http.MethodPost, path, multipartFieldName, filename, mimeType, bytes.NewReader(raw), &att); err != nil {
return to.ErrorResult(fmt.Errorf("create issue attachment err: %w", err))
}
return to.TextResult(att)
}
func EditIssueAttachmentFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called EditIssueAttachmentFn")
owner, _ := req.GetArguments()["owner"].(string)
repo, _ := req.GetArguments()["repo"].(string)
index, err := to.Float64(req.GetArguments()["index"])
if err != nil {
return to.ErrorResult(fmt.Errorf("index: %w", err))
}
aid, err := to.Float64(req.GetArguments()["attachment_id"])
if err != nil {
return to.ErrorResult(fmt.Errorf("attachment_id: %w", err))
}
name, _ := req.GetArguments()["name"].(string)
if name == "" {
return to.ErrorResult(fmt.Errorf("name is required"))
}
var att forgejo_sdk.Attachment
body := map[string]string{"name": name}
path := fmt.Sprintf("/repos/%s/%s/issues/%d/assets/%d", owner, repo, int64(index), int64(aid))
if err := forgejo.DoJSON(ctx, http.MethodPatch, path, body, &att); err != nil {
return to.ErrorResult(fmt.Errorf("edit issue attachment err: %w", err))
}
return to.TextResult(att)
}
func DeleteIssueAttachmentFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called DeleteIssueAttachmentFn")
owner, _ := req.GetArguments()["owner"].(string)
repo, _ := req.GetArguments()["repo"].(string)
index, err := to.Float64(req.GetArguments()["index"])
if err != nil {
return to.ErrorResult(fmt.Errorf("index: %w", err))
}
aid, err := to.Float64(req.GetArguments()["attachment_id"])
if err != nil {
return to.ErrorResult(fmt.Errorf("attachment_id: %w", err))
}
path := fmt.Sprintf("/repos/%s/%s/issues/%d/assets/%d", owner, repo, int64(index), int64(aid))
if err := forgejo.DoJSON(ctx, http.MethodDelete, path, nil, nil); err != nil {
return to.ErrorResult(fmt.Errorf("delete issue attachment err: %w", err))
}
return to.TextResult(map[string]string{"status": "deleted"})
}
// --- Comment-scoped handlers ------------------------------------------------
func ListCommentAttachmentsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called ListCommentAttachmentsFn")
owner, _ := req.GetArguments()["owner"].(string)
repo, _ := req.GetArguments()["repo"].(string)
cid, err := to.Float64(req.GetArguments()["comment_id"])
if err != nil {
return to.ErrorResult(fmt.Errorf("comment_id: %w", err))
}
var out []*forgejo_sdk.Attachment
path := fmt.Sprintf("/repos/%s/%s/issues/comments/%d/assets", owner, repo, int64(cid))
if err := forgejo.DoJSONList(ctx, http.MethodGet, path, &out); err != nil {
return to.ErrorResult(fmt.Errorf("list comment attachments err: %w", err))
}
if out == nil {
out = []*forgejo_sdk.Attachment{}
}
return to.TextResult(out)
}
func GetCommentAttachmentFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called GetCommentAttachmentFn")
owner, _ := req.GetArguments()["owner"].(string)
repo, _ := req.GetArguments()["repo"].(string)
cid, err := to.Float64(req.GetArguments()["comment_id"])
if err != nil {
return to.ErrorResult(fmt.Errorf("comment_id: %w", err))
}
aid, err := to.Float64(req.GetArguments()["attachment_id"])
if err != nil {
return to.ErrorResult(fmt.Errorf("attachment_id: %w", err))
}
att, err := getCommentAttachment(ctx, owner, repo, int64(cid), int64(aid))
if err != nil {
return to.ErrorResult(fmt.Errorf("get comment attachment err: %w", err))
}
return to.TextResult(att)
}
func DownloadCommentAttachmentFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called DownloadCommentAttachmentFn")
owner, _ := req.GetArguments()["owner"].(string)
repo, _ := req.GetArguments()["repo"].(string)
cid, err := to.Float64(req.GetArguments()["comment_id"])
if err != nil {
return to.ErrorResult(fmt.Errorf("comment_id: %w", err))
}
aid, err := to.Float64(req.GetArguments()["attachment_id"])
if err != nil {
return to.ErrorResult(fmt.Errorf("attachment_id: %w", err))
}
att, err := getCommentAttachment(ctx, owner, repo, int64(cid), int64(aid))
if err != nil {
return to.ErrorResult(fmt.Errorf("download comment attachment (metadata) err: %w", err))
}
return downloadResultFor(ctx, att)
}
func CreateCommentAttachmentFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called CreateCommentAttachmentFn")
owner, _ := req.GetArguments()["owner"].(string)
repo, _ := req.GetArguments()["repo"].(string)
cid, err := to.Float64(req.GetArguments()["comment_id"])
if err != nil {
return to.ErrorResult(fmt.Errorf("comment_id: %w", err))
}
content, _ := req.GetArguments()["content"].(string)
filename, _ := req.GetArguments()["filename"].(string)
mimeType, _ := req.GetArguments()["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))
}
var att forgejo_sdk.Attachment
path := fmt.Sprintf("/repos/%s/%s/issues/comments/%d/assets", owner, repo, int64(cid))
if err := forgejo.DoMultipart(ctx, http.MethodPost, path, multipartFieldName, filename, mimeType, bytes.NewReader(raw), &att); err != nil {
return to.ErrorResult(fmt.Errorf("create comment attachment err: %w", err))
}
return to.TextResult(att)
}
func EditCommentAttachmentFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called EditCommentAttachmentFn")
owner, _ := req.GetArguments()["owner"].(string)
repo, _ := req.GetArguments()["repo"].(string)
cid, err := to.Float64(req.GetArguments()["comment_id"])
if err != nil {
return to.ErrorResult(fmt.Errorf("comment_id: %w", err))
}
aid, err := to.Float64(req.GetArguments()["attachment_id"])
if err != nil {
return to.ErrorResult(fmt.Errorf("attachment_id: %w", err))
}
name, _ := req.GetArguments()["name"].(string)
if name == "" {
return to.ErrorResult(fmt.Errorf("name is required"))
}
var att forgejo_sdk.Attachment
body := map[string]string{"name": name}
path := fmt.Sprintf("/repos/%s/%s/issues/comments/%d/assets/%d", owner, repo, int64(cid), int64(aid))
if err := forgejo.DoJSON(ctx, http.MethodPatch, path, body, &att); err != nil {
return to.ErrorResult(fmt.Errorf("edit comment attachment err: %w", err))
}
return to.TextResult(att)
}
func DeleteCommentAttachmentFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called DeleteCommentAttachmentFn")
owner, _ := req.GetArguments()["owner"].(string)
repo, _ := req.GetArguments()["repo"].(string)
cid, err := to.Float64(req.GetArguments()["comment_id"])
if err != nil {
return to.ErrorResult(fmt.Errorf("comment_id: %w", err))
}
aid, err := to.Float64(req.GetArguments()["attachment_id"])
if err != nil {
return to.ErrorResult(fmt.Errorf("attachment_id: %w", err))
}
path := fmt.Sprintf("/repos/%s/%s/issues/comments/%d/assets/%d", owner, repo, int64(cid), int64(aid))
if err := forgejo.DoJSON(ctx, http.MethodDelete, path, nil, nil); err != nil {
return to.ErrorResult(fmt.Errorf("delete comment attachment err: %w", err))
}
return to.TextResult(map[string]string{"status": "deleted"})
}
// --- shared helpers ---------------------------------------------------------
func getIssueAttachment(ctx context.Context, owner, repo string, index, aid int64) (*forgejo_sdk.Attachment, error) {
var att forgejo_sdk.Attachment
path := fmt.Sprintf("/repos/%s/%s/issues/%d/assets/%d", owner, repo, index, aid)
if err := forgejo.DoJSON(ctx, http.MethodGet, path, nil, &att); err != nil {
return nil, err
}
return &att, nil
}
func getCommentAttachment(ctx context.Context, owner, repo string, cid, aid int64) (*forgejo_sdk.Attachment, error) {
var att forgejo_sdk.Attachment
path := fmt.Sprintf("/repos/%s/%s/issues/comments/%d/assets/%d", owner, repo, cid, aid)
if err := forgejo.DoJSON(ctx, http.MethodGet, path, nil, &att); err != nil {
return nil, err
}
return &att, nil
}
// downloadResultFor fetches inline bytes when the attachment is under the
// inline cap; otherwise returns metadata + URL only and instructs the caller
// to fetch via curl. Always includes browser_download_url.
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 {
// Defensive: if the size advertised was under cap but the actual bytes
// blow past it, treat that the same as "too big" rather than failing.
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))
uri := att.DownloadURL
mimeType := ct
encoded := base64.StdEncoding.EncodeToString(body)
// NewToolResultResource gives us a CallToolResult containing both a
// text content (the metadata-as-JSON) and an embedded BlobResourceContents.
// MCP clients that don't know about embedded resources still see the JSON.
textPart := to.SafeJSONMarshal(res)
return mcp.NewToolResultResource(textPart, mcp.BlobResourceContents{
URI: uri,
MIMEType: mimeType,
Blob: encoded,
}), nil
}