forgejo-mcp/demos/issue-attachments.md
Christoph Görn 03aaf58679
feat: ✨ add issue & comment attachment tools (closes #106)
Implements 12 new MCP tools for full CRUD on issue and comment
attachments via a new raw-HTTP helper, bypassing the SDK gap.

New packages:
- pkg/forgejo/rawhttp.go: DoJSON, DoJSONList, DoMultipart, DoRaw
  with 1 MiB inline cap (ErrPayloadTooLarge sentinel), auth-header
  propagation, and 401/403/404 error sentinels
- operation/attachment/attachment.go: 12 tools registered under the
  new "attachment" CLI domain

Key design decisions (see docs/plans/issue-attachments.md):
- Files < 1 MiB: inline as BlobResourceContents + metadata JSON
- Files >= 1 MiB: metadata + browser_download_url only; caller fetches
  with Authorization: token (verified live against Codeberg API)
- Always includes browser_download_url regardless of size
- 404 on list endpoints treated as empty array, not error

Also:
- operation/params/params.go: attachment param descriptions
- operation/operation.go + cmd/cli.go: registration wired
- README.md: tool table updated
- demos/issue-attachments.md + demos/comment-attachments.md added
2026-04-26 13:10:49 +02:00

5.3 KiB

Demo: issue attachments — full CRUD

2026-04-26T13:10:00Z

What these tools do

Six MCP tools cover the full attachment lifecycle on issues and pull requests, closing the gap reported in #106 where attachments uploaded via the web UI were invisible to MCP clients:

  • list_issue_attachments — Enumerate attachments on an issue/PR.
  • get_issue_attachment — Fetch metadata for a single attachment.
  • download_issue_attachment — Pull the bytes (inline if < 1 MiB; metadata + URL otherwise).
  • create_issue_attachment — Upload a new attachment from base64 content.
  • edit_issue_attachment — Rename an attachment.
  • delete_issue_attachment — Remove an attachment.

The split between inline bytes and metadata + URL is deliberate. For files under the 1 MiB cap, the response carries the file as a base64-encoded BlobResourceContents (the MCP-protocol-native binary mechanism). For larger files, the response includes only metadata and the browser_download_url; the caller is expected to fetch that URL with the same auth token. See docs/plans/issue-attachments.md for the design rationale and the discussion on issue #106.

Setup

export FORGEJO_URL=https://codeberg.org
export FORGEJO_ACCESS_TOKEN=...
make build

End-to-end lifecycle

1. Upload a file (base64-encoded)

B64=$(base64 -w0 /tmp/demo.txt)
./forgejo-mcp --cli create_issue_attachment \
  --args "{\"owner\":\"goern\",\"repo\":\"forgejo-mcp\",\"index\":108,\"content\":\"$B64\",\"filename\":\"demo-notes.txt\",\"mime_type\":\"text/plain\"}"
[
  {
    "type": "text",
    "text": "{\"Result\":{\"id\":1174982,\"name\":\"demo-notes.txt\",\"size\":67,\"download_count\":0,\"created_at\":\"2026-04-26T13:08:14+02:00\",\"uuid\":\"d6f13ced-2fc6-4319-8160-10f15ce55b0a\",\"browser_download_url\":\"https://codeberg.org/attachments/d6f13ced-2fc6-4319-8160-10f15ce55b0a\"}}"
  }
]

2. List attachments

./forgejo-mcp --cli list_issue_attachments \
  --args '{"owner":"goern","repo":"forgejo-mcp","index":108}'
[
  {
    "type": "text",
    "text": "{\"Result\":[{\"id\":1174982,\"name\":\"demo-notes.txt\",\"size\":67,\"...\"}]}"
  }
]

3. Get single-attachment metadata

./forgejo-mcp --cli get_issue_attachment \
  --args '{"owner":"goern","repo":"forgejo-mcp","index":108,"attachment_id":1174982}'

4. Download — inline (file is under 1 MiB)

./forgejo-mcp --cli download_issue_attachment \
  --args '{"owner":"goern","repo":"forgejo-mcp","index":108,"attachment_id":1174982}'
[
  {
    "type": "text",
    "text": "{\"attachment\":{\"id\":1174982,\"name\":\"demo-notes.txt\",\"size\":67,\"...\":\"https://codeberg.org/attachments/d6f13ced-2fc6-4319-8160-10f15ce55b0a\"},\"inline\":true,\"bytes_included\":67}"
  },
  {
    "type": "resource",
    "resource": {
      "uri": "https://codeberg.org/attachments/d6f13ced-2fc6-4319-8160-10f15ce55b0a",
      "mimeType": "text/plain; charset=utf-8",
      "blob": "RGVtbyBQREYgY29udGVudCBmb3IgZm9yZ2Vqby1tY3AgaXNzdWUtYXR0YWNobWVudHMuIFNIQT0xNzc3MjAxNjk0Cg=="
    }
  }
]

The response carries two content blocks: a JSON text part (metadata + inline:true + bytes_included) and an EmbeddedResource whose Blob is the base64-encoded file content.

5. Download — over the cap (file ≥ 1 MiB)

When the attachment exceeds the 1 MiB inline cap, the tool returns only metadata; no Blob field. The caller fetches the bytes with curl using the same token:

[
  {
    "type": "text",
    "text": "{\"attachment\":{...,\"size\":2097152,\"browser_download_url\":\"https://codeberg.org/attachments/abc\"},\"inline\":false,\"reason\":\"size 2097152 bytes >= inline cap 1048576; fetch browser_download_url with Authorization: token <TOKEN>\"}"
  }
]
curl -H "Authorization: token $FORGEJO_ACCESS_TOKEN" \
  https://codeberg.org/attachments/abc -o big.bin

6. Rename

./forgejo-mcp --cli edit_issue_attachment \
  --args '{"owner":"goern","repo":"forgejo-mcp","index":108,"attachment_id":1174982,"name":"renamed-demo.txt"}'

7. Delete

./forgejo-mcp --cli delete_issue_attachment \
  --args '{"owner":"goern","repo":"forgejo-mcp","index":108,"attachment_id":1174982}'
[
  {
    "type": "text",
    "text": "{\"Result\":{\"status\":\"deleted\"}}"
  }
]

8. List again — empty

./forgejo-mcp --cli list_issue_attachments \
  --args '{"owner":"goern","repo":"forgejo-mcp","index":108}'
[
  {
    "type": "text",
    "text": "{\"Result\":[]}"
  }
]

A list against an issue with no attachments returns an empty array, never a 404 — the raw-HTTP helper transparently maps Forgejo's 404-on-empty-list quirk to [].

Why the 1 MiB cap

The 1 MiB cap on inline base64 is the result of design discussion on issue #106. Earlier iterations proposed a 10 MiB cap or a save_to_path parameter; both were rejected. The 1 MiB cap keeps small attachments ergonomic in the MCP response while preventing context-window blowout, and the always-included browser_download_url lets agents fall through to a direct authenticated fetch for anything bigger. No file paths cross the MCP boundary, so the same code works identically across stdio, SSE, and streamable-HTTP transports.