feat(actions): add artifact listing and download (#210)

Adds the ability to list and download Gitea Actions artifacts. Extends the existing `actions_run_read` tool with four new methods rather than adding a separate tool, keeping artifacts alongside the runs/jobs/logs they belong to.

### New methods (`actions_run_read`)

| Method               | Description                                | Key params                                             |
| -------------------- | ------------------------------------------ | ------------------------------------------------------ |
| `list_artifacts`     | List all artifacts in a repository         | `owner`, `repo`, optional `artifact_name` filter       |
| `list_run_artifacts` | List artifacts for one workflow run        | `owner`, `repo`, `run_id`                              |
| `get_artifact`       | Get metadata for a single artifact         | `owner`, `repo`, `artifact_id`                         |
| `download_artifact`  | Download an artifact's zip archive to disk | `owner`, `repo`, `artifact_id`, optional `output_path` |Reviewed-on: https://gitea.com/gitea/gitea-mcp/pulls/210
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: bircni <bircni@icloud.com>
This commit is contained in:
bircni
2026-07-19 19:50:17 +00:00
committed by Lunny Xiao
parent bbde7ee110
commit 93346f2c42
5 changed files with 259 additions and 5 deletions
+4
View File
@@ -302,6 +302,10 @@ The Gitea MCP Server supports the following tools:
| list_repo_action_run_jobs | Actions | List Actions jobs for a run | | list_repo_action_run_jobs | Actions | List Actions jobs for a run |
| get_repo_action_job_log_preview | Actions | Get a job log preview (tail/limited) | | get_repo_action_job_log_preview | Actions | Get a job log preview (tail/limited) |
| download_repo_action_job_log | Actions | Download a job log to a file | | download_repo_action_job_log | Actions | Download a job log to a file |
| list_repo_action_artifacts | Actions | List repository Actions artifacts |
| list_repo_action_run_artifacts | Actions | List Actions artifacts for a run |
| get_repo_action_artifact | Actions | Get a repository Actions artifact |
| download_repo_action_artifact | Actions | Download an Actions artifact zip to a file |
| get_gitea_mcp_server_version | Server | Get the version of the Gitea MCP Server | | get_gitea_mcp_server_version | Server | Get the version of the Gitea MCP Server |
| list_wiki_pages | Wiki | List all wiki pages in a repository | | list_wiki_pages | Wiki | List all wiki pages in a repository |
| get_wiki_page | Wiki | Get a wiki page content and metadata | | get_wiki_page | Wiki | Get a wiki page content and metadata |
+195
View File
@@ -0,0 +1,195 @@
package actions
import (
"context"
"errors"
"fmt"
"net/http"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"gitea.com/gitea/gitea-mcp/pkg/gitea"
"gitea.com/gitea/gitea-mcp/pkg/params"
"gitea.com/gitea/gitea-mcp/pkg/to"
"github.com/mark3labs/mcp-go/mcp"
)
// Artifact endpoints require Gitea 1.25+. Older servers answer 404/405, which is
// surfaced as a clear "not supported" message rather than a raw HTTP error.
func artifactNotSupportedErr(err error) error {
var httpErr *gitea.HTTPError
if errors.As(err, &httpErr) && (httpErr.StatusCode == http.StatusNotFound || httpErr.StatusCode == http.StatusMethodNotAllowed) {
return fmt.Errorf("actions artifacts not supported on this Gitea version (endpoint returned %d, requires Gitea 1.25+). Check https://docs.gitea.com/api/1.25/ for available Actions endpoints", httpErr.StatusCode)
}
return err
}
func listRepoActionArtifactsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil {
return to.ErrorResult(err)
}
page, pageSize := params.GetPagination(req.GetArguments(), 30)
query := url.Values{}
query.Set("page", strconv.Itoa(page))
query.Set("limit", strconv.Itoa(pageSize))
if name := params.GetOptionalString(req.GetArguments(), "artifact_name", ""); name != "" {
query.Set("name", name)
}
var result any
err = doJSONWithFallback(ctx, "GET",
[]string{
fmt.Sprintf("repos/%s/%s/actions/artifacts", url.PathEscape(owner), url.PathEscape(repo)),
},
query, nil, &result,
)
if err != nil {
return to.ErrorResult(fmt.Errorf("list action artifacts err: %v", artifactNotSupportedErr(err)))
}
return to.TextResult(slimActionArtifacts(result))
}
func listRepoActionRunArtifactsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil {
return to.ErrorResult(err)
}
runID, err := params.GetIndex(req.GetArguments(), "run_id")
if err != nil || runID <= 0 {
return to.ErrorResult(errors.New("run_id is required"))
}
page, pageSize := params.GetPagination(req.GetArguments(), 30)
query := url.Values{}
query.Set("page", strconv.Itoa(page))
query.Set("limit", strconv.Itoa(pageSize))
if name := params.GetOptionalString(req.GetArguments(), "artifact_name", ""); name != "" {
query.Set("name", name)
}
var result any
err = doJSONWithFallback(ctx, "GET",
[]string{
fmt.Sprintf("repos/%s/%s/actions/runs/%d/artifacts", url.PathEscape(owner), url.PathEscape(repo), runID),
},
query, nil, &result,
)
if err != nil {
return to.ErrorResult(fmt.Errorf("list action run artifacts err: %v", artifactNotSupportedErr(err)))
}
return to.TextResult(slimActionArtifacts(result))
}
func getRepoActionArtifactFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil {
return to.ErrorResult(err)
}
artifactID, err := params.GetIndex(req.GetArguments(), "artifact_id")
if err != nil || artifactID <= 0 {
return to.ErrorResult(errors.New("artifact_id is required"))
}
var result any
err = doJSONWithFallback(ctx, "GET",
[]string{
fmt.Sprintf("repos/%s/%s/actions/artifacts/%d", url.PathEscape(owner), url.PathEscape(repo), artifactID),
},
nil, nil, &result,
)
if err != nil {
return to.ErrorResult(fmt.Errorf("get action artifact err: %v", artifactNotSupportedErr(err)))
}
return to.TextResult(slimActionArtifact(result))
}
func downloadRepoActionArtifactFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
owner, err := params.GetString(req.GetArguments(), "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(req.GetArguments(), "repo")
if err != nil {
return to.ErrorResult(err)
}
artifactID, err := params.GetIndex(req.GetArguments(), "artifact_id")
if err != nil || artifactID <= 0 {
return to.ErrorResult(errors.New("artifact_id is required"))
}
outputPath, _ := req.GetArguments()["output_path"].(string)
// Best-effort metadata lookup: gives a friendly filename and lets us fail
// early with a clear message when the artifact has expired.
var meta map[string]any
_ = doJSONWithFallback(ctx, "GET",
[]string{fmt.Sprintf("repos/%s/%s/actions/artifacts/%d", url.PathEscape(owner), url.PathEscape(repo), artifactID)},
nil, nil, &meta,
)
if expired, ok := meta["expired"].(bool); ok && expired {
return to.ErrorResult(fmt.Errorf("artifact %d has expired and is no longer downloadable", artifactID))
}
// The zip endpoint answers with a 302 redirect to signed blob storage;
// DoBytes follows GET redirects and returns the archive bytes.
raw, _, err := gitea.DoBytes(ctx, "GET",
fmt.Sprintf("repos/%s/%s/actions/artifacts/%d/zip", url.PathEscape(owner), url.PathEscape(repo), artifactID),
nil, nil, "application/zip",
)
if err != nil {
return to.ErrorResult(fmt.Errorf("download action artifact err: %v", artifactNotSupportedErr(err)))
}
if outputPath == "" {
home, _ := os.UserHomeDir()
if home == "" {
home = os.TempDir()
}
outputPath = filepath.Join(home, ".gitea-mcp", "artifacts", "actions-artifacts", owner, repo, artifactFilename(meta, artifactID))
}
if err := os.MkdirAll(filepath.Dir(outputPath), 0o700); err != nil {
return to.ErrorResult(fmt.Errorf("create output dir err: %v", err))
}
if err := os.WriteFile(outputPath, raw, 0o600); err != nil {
return to.ErrorResult(fmt.Errorf("write artifact file err: %v", err))
}
res := map[string]any{
"artifact_id": artifactID,
"path": outputPath,
"bytes": len(raw),
}
if name, ok := meta["name"].(string); ok && name != "" {
res["name"] = name
}
return to.TextResult(res)
}
// artifactFilename derives a safe "<name>.zip" filename from artifact metadata,
// falling back to the artifact ID when the name is missing or path-unsafe.
func artifactFilename(meta map[string]any, artifactID int64) string {
name, _ := meta["name"].(string)
name = strings.TrimSpace(name)
if name != "" && !strings.ContainsAny(name, `/\`) && name != "." && name != ".." {
return name + ".zip"
}
return fmt.Sprintf("%d.zip", artifactID)
}
+29
View File
@@ -0,0 +1,29 @@
package actions
import "testing"
func TestArtifactFilename(t *testing.T) {
tests := []struct {
name string
meta map[string]any
artifactID int64
want string
}{
{"uses name", map[string]any{"name": "build-output"}, 7, "build-output.zip"},
{"trims whitespace", map[string]any{"name": " logs "}, 7, "logs.zip"},
{"missing name falls back to id", map[string]any{}, 7, "7.zip"},
{"empty name falls back to id", map[string]any{"name": ""}, 7, "7.zip"},
{"non-string name falls back to id", map[string]any{"name": 42}, 7, "7.zip"},
{"rejects forward slash traversal", map[string]any{"name": "../etc/passwd"}, 7, "7.zip"},
{"rejects backslash traversal", map[string]any{"name": `..\win`}, 7, "7.zip"},
{"rejects dot", map[string]any{"name": "."}, 7, "7.zip"},
{"rejects dotdot", map[string]any{"name": ".."}, 7, "7.zip"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := artifactFilename(tt.meta, tt.artifactID); got != tt.want {
t.Errorf("artifactFilename() = %q, want %q", got, tt.want)
}
})
}
}
+15 -5
View File
@@ -27,18 +27,20 @@ const (
var ( var (
ActionsRunReadTool = mcp.NewTool( ActionsRunReadTool = mcp.NewTool(
ActionsRunReadToolName, ActionsRunReadToolName,
mcp.WithDescription("Read Actions workflows, runs, jobs, and logs."), mcp.WithDescription("Read Actions workflows, runs, jobs, logs, and artifacts."),
mcp.WithToolAnnotation(annotation.ReadOnly("Read Actions workflow, run, and job data")), mcp.WithToolAnnotation(annotation.ReadOnly("Read Actions workflow, run, job, and artifact data")),
mcp.WithString("method", mcp.Required(), mcp.Enum("list_workflows", "get_workflow", "list_runs", "get_run", "list_jobs", "list_run_jobs", "get_job_log_preview", "download_job_log")), mcp.WithString("method", mcp.Required(), mcp.Enum("list_workflows", "get_workflow", "list_runs", "get_run", "list_jobs", "list_run_jobs", "get_job_log_preview", "download_job_log", "list_artifacts", "list_run_artifacts", "get_artifact", "download_artifact")),
mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)), mcp.WithString("owner", mcp.Required(), mcp.Description(params.OwnerDesc)),
mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)), mcp.WithString("repo", mcp.Required(), mcp.Description(params.RepoDesc)),
mcp.WithString("workflow_id", mcp.Description("ID or filename (for 'get_workflow')")), mcp.WithString("workflow_id", mcp.Description("ID or filename (for 'get_workflow')")),
mcp.WithNumber("run_id", mcp.Description("for 'get_run'/'list_run_jobs'")), mcp.WithNumber("run_id", mcp.Description("for 'get_run'/'list_run_jobs'/'list_run_artifacts'")),
mcp.WithNumber("job_id", mcp.Description("for log methods")), mcp.WithNumber("job_id", mcp.Description("for log methods")),
mcp.WithNumber("artifact_id", mcp.Description("for 'get_artifact'/'download_artifact'")),
mcp.WithString("artifact_name", mcp.Description("name filter for 'list_artifacts'/'list_run_artifacts'")),
mcp.WithString("status", mcp.Description("filter for 'list_runs'/'list_jobs'")), mcp.WithString("status", mcp.Description("filter for 'list_runs'/'list_jobs'")),
mcp.WithNumber("tail_lines", mcp.Description("log tail lines"), mcp.DefaultNumber(200), mcp.Min(1)), mcp.WithNumber("tail_lines", mcp.Description("log tail lines"), mcp.DefaultNumber(200), mcp.Min(1)),
mcp.WithNumber("max_bytes", mcp.Description("max log bytes"), mcp.DefaultNumber(65536), mcp.Min(1024)), mcp.WithNumber("max_bytes", mcp.Description("max log bytes"), mcp.DefaultNumber(65536), mcp.Min(1024)),
mcp.WithString("output_path", mcp.Description("for 'download_job_log'")), mcp.WithString("output_path", mcp.Description("for 'download_job_log'/'download_artifact'")),
mcp.WithNumber("page", mcp.Description(params.PageDesc), mcp.DefaultNumber(1), mcp.Min(1)), mcp.WithNumber("page", mcp.Description(params.PageDesc), mcp.DefaultNumber(1), mcp.Min(1)),
mcp.WithNumber("per_page", mcp.Description(params.PaginationDesc), mcp.DefaultNumber(30), mcp.Min(1)), mcp.WithNumber("per_page", mcp.Description(params.PaginationDesc), mcp.DefaultNumber(30), mcp.Min(1)),
) )
@@ -84,6 +86,14 @@ func runReadFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResul
return getRepoActionJobLogPreviewFn(ctx, req) return getRepoActionJobLogPreviewFn(ctx, req)
case "download_job_log": case "download_job_log":
return downloadRepoActionJobLogFn(ctx, req) return downloadRepoActionJobLogFn(ctx, req)
case "list_artifacts":
return listRepoActionArtifactsFn(ctx, req)
case "list_run_artifacts":
return listRepoActionRunArtifactsFn(ctx, req)
case "get_artifact":
return getRepoActionArtifactFn(ctx, req)
case "download_artifact":
return downloadRepoActionArtifactFn(ctx, req)
default: default:
return to.ErrorResult(fmt.Errorf("unknown method: %s", method)) return to.ErrorResult(fmt.Errorf("unknown method: %s", method))
} }
+16
View File
@@ -90,3 +90,19 @@ func slimActionWorkflow(raw any) any {
func slimActionWorkflows(raw any) any { func slimActionWorkflows(raw any) any {
return slimPaginated(raw, slimWorkflow) return slimPaginated(raw, slimWorkflow)
} }
func slimArtifact(m map[string]any) map[string]any {
return pick(m, "id", "name", "size_in_bytes", "expired",
"created_at", "updated_at", "expires_at")
}
func slimActionArtifact(raw any) any {
if m, ok := raw.(map[string]any); ok {
return slimArtifact(m)
}
return raw
}
func slimActionArtifacts(raw any) any {
return slimPaginated(raw, slimArtifact)
}