Inline issue/comment attachments in body (#183)
The Gitea API returns an `assets` array on issue and comment responses, but the SDK structs drop it — so attachments are invisible to MCP agents.
Append each attachment as a `[name](url)` markdown link at the end of the body, mirroring how GitHub embeds attachments inline (which `github-mcp-server` preserves as-is).
**Coverage:**
- `issue_read get` — issue body attachments
- `issue_read get_comments` — issue and PR conversation comment attachments (same endpoint)
- `pull_request_read get` — PR description attachments (Gitea's `/pulls/` endpoint omits `assets`, so a follow-up best-effort call to `/issues/{n}/assets` surfaces them; PRs are issues internally)
PR review summaries and line-comment reviews don't support attachments per the Gitea API spec, so nothing to do there.
Closes https://gitea.com/gitea/gitea-mcp/issues/182
---
This PR was written with the help of Claude Opus 4.7
Reviewed-on: https://gitea.com/gitea/gitea-mcp/pulls/183
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: silverwind <me@silverwind.io>
Co-committed-by: silverwind <me@silverwind.io>
This commit is contained in:
@@ -6,6 +6,7 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
@@ -770,3 +771,143 @@ func Test_getPullRequestDiffFn(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_getPullRequestByIndexFn_includesAttachments(t *testing.T) {
|
||||
const (
|
||||
owner = "octo"
|
||||
repo = "demo"
|
||||
index = 9
|
||||
)
|
||||
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/api/v1/version":
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"version":"1.12.0"}`))
|
||||
case fmt.Sprintf("/api/v1/repos/%s/%s", owner, repo):
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"private":false}`))
|
||||
case fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d", owner, repo, index):
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"number":9,"title":"feat","body":"see screenshot","state":"open"}`))
|
||||
case fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/assets", owner, repo, index):
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`[{"id":1,"name":"shot.png","browser_download_url":"https://example/shot.png"}]`))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
})
|
||||
server := httptest.NewServer(handler)
|
||||
defer server.Close()
|
||||
|
||||
origHost, origToken, origVersion := flag.Host, flag.Token, flag.Version
|
||||
flag.Host, flag.Token, flag.Version = server.URL, "", "test"
|
||||
defer func() { flag.Host, flag.Token, flag.Version = origHost, origToken, origVersion }()
|
||||
|
||||
req := mcp.CallToolRequest{Params: mcp.CallToolParams{Arguments: map[string]any{
|
||||
"owner": owner, "repo": repo, "index": float64(index),
|
||||
}}}
|
||||
res, err := getPullRequestByIndexFn(context.Background(), req)
|
||||
if err != nil {
|
||||
t.Fatalf("getPullRequestByIndexFn() error = %v", err)
|
||||
}
|
||||
if res.IsError {
|
||||
t.Fatalf("unexpected error result: %v", res.Content)
|
||||
}
|
||||
body := res.Content[0].(mcp.TextContent).Text
|
||||
if !strings.Contains(body, `[shot.png](https://example/shot.png)`) {
|
||||
t.Fatalf("expected attachment markdown inlined in body, got: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func Test_getPullRequestByIndexFn_emptyAssetsLeavesBody(t *testing.T) {
|
||||
const (
|
||||
owner = "octo"
|
||||
repo = "demo"
|
||||
index = 9
|
||||
)
|
||||
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/api/v1/version":
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"version":"1.12.0"}`))
|
||||
case fmt.Sprintf("/api/v1/repos/%s/%s", owner, repo):
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"private":false}`))
|
||||
case fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d", owner, repo, index):
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"number":9,"title":"feat","body":"plain body","state":"open"}`))
|
||||
case fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/assets", owner, repo, index):
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`[]`))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
})
|
||||
server := httptest.NewServer(handler)
|
||||
defer server.Close()
|
||||
|
||||
origHost, origToken, origVersion := flag.Host, flag.Token, flag.Version
|
||||
flag.Host, flag.Token, flag.Version = server.URL, "", "test"
|
||||
defer func() { flag.Host, flag.Token, flag.Version = origHost, origToken, origVersion }()
|
||||
|
||||
req := mcp.CallToolRequest{Params: mcp.CallToolParams{Arguments: map[string]any{
|
||||
"owner": owner, "repo": repo, "index": float64(index),
|
||||
}}}
|
||||
res, err := getPullRequestByIndexFn(context.Background(), req)
|
||||
if err != nil {
|
||||
t.Fatalf("getPullRequestByIndexFn() error = %v", err)
|
||||
}
|
||||
body := res.Content[0].(mcp.TextContent).Text
|
||||
if !strings.Contains(body, `"body":"plain body"`) {
|
||||
t.Fatalf("expected body unchanged when assets are empty, got: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func Test_getPullRequestByIndexFn_assetsFailureNonFatal(t *testing.T) {
|
||||
const (
|
||||
owner = "octo"
|
||||
repo = "demo"
|
||||
index = 9
|
||||
)
|
||||
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/api/v1/version":
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"version":"1.12.0"}`))
|
||||
case fmt.Sprintf("/api/v1/repos/%s/%s", owner, repo):
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"private":false}`))
|
||||
case fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d", owner, repo, index):
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"number":9,"title":"feat","body":"plain body","state":"open"}`))
|
||||
case fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/assets", owner, repo, index):
|
||||
http.Error(w, "boom", http.StatusInternalServerError)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
})
|
||||
server := httptest.NewServer(handler)
|
||||
defer server.Close()
|
||||
|
||||
origHost, origToken, origVersion := flag.Host, flag.Token, flag.Version
|
||||
flag.Host, flag.Token, flag.Version = server.URL, "", "test"
|
||||
defer func() { flag.Host, flag.Token, flag.Version = origHost, origToken, origVersion }()
|
||||
|
||||
req := mcp.CallToolRequest{Params: mcp.CallToolParams{Arguments: map[string]any{
|
||||
"owner": owner, "repo": repo, "index": float64(index),
|
||||
}}}
|
||||
res, err := getPullRequestByIndexFn(context.Background(), req)
|
||||
if err != nil {
|
||||
t.Fatalf("getPullRequestByIndexFn() error = %v", err)
|
||||
}
|
||||
if res.IsError {
|
||||
t.Fatalf("assets fetch failure should not fail the PR fetch: %v", res.Content)
|
||||
}
|
||||
body := res.Content[0].(mcp.TextContent).Text
|
||||
if !strings.Contains(body, `"plain body"`) {
|
||||
t.Fatalf("expected PR body preserved when assets fail, got: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user