forgejo-mcp/demos/issue-attachments.md
Christoph Görn cf7d392086 feat: add issue & comment attachment tools (#109)
Closes #106.

Adds 12 new MCP tools (list/get/download/create/edit/delete × issue +
comment) backed by a new pkg/forgejo/rawhttp.go helper. Download tools
return inline BlobResourceContents under 1 MiB and metadata +
browser_download_url at or above the cap, per the design agreed with
@heathen711 on issue #106.

Includes unit tests, demos, and an e2e script under test/e2e/.
2026-05-02 12:06:54 +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.