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/.
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.