package actions import ( "context" "fmt" "net/url" "os" "path/filepath" "strconv" "gitea.com/gitea/gitea-mcp/pkg/errors" "gitea.com/gitea/gitea-mcp/pkg/gitea" "gitea.com/gitea/gitea-mcp/pkg/log" "gitea.com/gitea/gitea-mcp/pkg/params" "gitea.com/gitea/gitea-mcp/pkg/to" "github.com/mark3labs/mcp-go/mcp" "github.com/mark3labs/mcp-go/server" ) const ( DefaultMaxArtifactSize = 100 * 1024 * 1024 ActionsArtifactToolName = "list_action_artifacts" ) var ( ActionsArtifactTool = mcp.NewTool( ActionsArtifactToolName, mcp.WithDescription("List and download artifacts from workflow runs. Use method 'list' to list artifacts, 'get' to get a specific artifact, 'download' to download artifact content."), mcp.WithString("method", mcp.Required(), mcp.Description("operation to perform"), mcp.Enum("list", "get", "download")), mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")), mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")), mcp.WithNumber("run_id", mcp.Description("run ID to filter artifacts (optional for list, required for get/download)")), mcp.WithString("artifact_name", mcp.Description("artifact name to filter (optional)")), mcp.WithNumber("artifact_id", mcp.Description("artifact ID (required for 'get' and 'download' methods)")), mcp.WithString("output_path", mcp.Description("output file path (for 'download' method). If not specified, saves to ~/.gitea-mcp/artifacts/")), mcp.WithNumber("max_size", mcp.Description("maximum artifact size in bytes to download (default 100MB)"), mcp.DefaultNumber(DefaultMaxArtifactSize), mcp.Min(1024)), mcp.WithNumber("page", mcp.Description("page number"), mcp.DefaultNumber(1), mcp.Min(1)), mcp.WithNumber("perPage", mcp.Description("results per page"), mcp.DefaultNumber(30), mcp.Min(1)), ) ) func init() { Tool.RegisterRead(server.ServerTool{Tool: ActionsArtifactTool, Handler: artifactHandler}) } func artifactHandler(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { method, err := params.GetString(req.GetArguments(), "method") if err != nil { return to.ErrorResult(errors.TranslateError(err, map[string]string{ "operation": "ListActionArtifacts", "param": "method", })) } switch method { case "list": return listActionArtifactsFn(ctx, req) case "get": return getActionArtifactFn(ctx, req) case "download": return downloadActionArtifactFn(ctx, req) default: return to.ErrorResult(errors.NewEnhancedError( fmt.Errorf("unknown method: %s", method), "Invalid method. Use 'list', 'get', or 'download'", errors.CategoryActions, ).WithOperation("ListActionArtifacts")) } } func listActionArtifactsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { log.Debugf("Called listActionArtifactsFn") owner, err := params.GetString(req.GetArguments(), "owner") if err != nil || owner == "" { return to.ErrorResult(errors.NewEnhancedError( err, "owner is required", errors.CategoryActions, ).WithOperation("ListActionArtifacts")) } repo, err := params.GetString(req.GetArguments(), "repo") if err != nil || repo == "" { return to.ErrorResult(errors.NewEnhancedError( err, "repo is required", errors.CategoryActions, ).WithOperation("ListActionArtifacts")) } page, pageSize := params.GetPagination(req.GetArguments(), 30) artifactName, _ := req.GetArguments()["artifact_name"].(string) var runID int64 if runIDVal, exists := req.GetArguments()["run_id"]; exists { if runIDFloat, ok := runIDVal.(float64); ok { runID = int64(runIDFloat) } } query := url.Values{} query.Set("page", strconv.Itoa(page)) query.Set("limit", strconv.Itoa(pageSize)) if artifactName != "" { query.Set("name", artifactName) } if runID > 0 { query.Set("run_id", strconv.FormatInt(runID, 10)) } 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 { if errors.IsActionsAPIUnavailable(err) { return to.TextResult(map[string]any{ "artifacts": []any{}, "total_count": 0, "message": "Actions API not available on this Gitea version", }) } return to.ErrorResult(errors.TranslateError(err, map[string]string{ "operation": "ListActionArtifacts", "owner": owner, "repo": repo, })) } return to.TextResult(slimActionArtifacts(result)) } func getActionArtifactFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { log.Debugf("Called getActionArtifactFn") owner, err := params.GetString(req.GetArguments(), "owner") if err != nil || owner == "" { return to.ErrorResult(errors.NewEnhancedError( err, "owner is required", errors.CategoryActions, ).WithOperation("GetActionArtifact")) } repo, err := params.GetString(req.GetArguments(), "repo") if err != nil || repo == "" { return to.ErrorResult(errors.NewEnhancedError( err, "repo is required", errors.CategoryActions, ).WithOperation("GetActionArtifact")) } artifactID, err := params.GetIndex(req.GetArguments(), "artifact_id") if err != nil || artifactID <= 0 { return to.ErrorResult(errors.NewEnhancedError( err, "artifact_id is required", errors.CategoryActions, ).WithOperation("GetActionArtifact")) } 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(errors.TranslateError(err, map[string]string{ "operation": "GetActionArtifact", "owner": owner, "repo": repo, "artifact_id": strconv.FormatInt(artifactID, 10), })) } return to.TextResult(slimActionArtifact(result)) } func downloadActionArtifactFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { log.Debugf("Called downloadActionArtifactFn") owner, err := params.GetString(req.GetArguments(), "owner") if err != nil || owner == "" { return to.ErrorResult(errors.NewEnhancedError( err, "owner is required", errors.CategoryActions, ).WithOperation("DownloadActionArtifact")) } repo, err := params.GetString(req.GetArguments(), "repo") if err != nil || repo == "" { return to.ErrorResult(errors.NewEnhancedError( err, "repo is required", errors.CategoryActions, ).WithOperation("DownloadActionArtifact")) } artifactID, err := params.GetIndex(req.GetArguments(), "artifact_id") if err != nil || artifactID <= 0 { return to.ErrorResult(errors.NewEnhancedError( err, "artifact_id is required", errors.CategoryActions, ).WithOperation("DownloadActionArtifact")) } maxSize := int64(params.GetOptionalInt(req.GetArguments(), "max_size", DefaultMaxArtifactSize)) outputPath, _ := req.GetArguments()["output_path"].(string) var artifactInfo any err = doJSONWithFallback(ctx, "GET", []string{ fmt.Sprintf("repos/%s/%s/actions/artifacts/%d", url.PathEscape(owner), url.PathEscape(repo), artifactID), }, nil, nil, &artifactInfo, ) if err != nil { return to.ErrorResult(errors.TranslateError(err, map[string]string{ "operation": "DownloadActionArtifact", "owner": owner, "repo": repo, "artifact_id": strconv.FormatInt(artifactID, 10), })) } var artifactSize int64 if info, ok := artifactInfo.(map[string]any); ok { if size, ok := info["size_in_bytes"].(float64); ok { artifactSize = int64(size) } } if artifactSize > maxSize { return to.ErrorResult(errors.NewEnhancedError( fmt.Errorf("artifact size %d exceeds maximum allowed size %d", artifactSize, maxSize), fmt.Sprintf("Artifact size (%s) exceeds maximum allowed size (%s). Use max_size parameter to increase limit.", formatBytes(artifactSize), formatBytes(maxSize)), errors.CategoryActions, ).WithOperation("DownloadActionArtifact"). WithParam("owner", owner). WithParam("repo", repo). WithParam("artifact_id", strconv.FormatInt(artifactID, 10))) } artifactBytes, _, err := gitea.DoBytes(ctx, "GET", fmt.Sprintf("repos/%s/%s/actions/artifacts/%d/download", url.PathEscape(owner), url.PathEscape(repo), artifactID), nil, nil, "application/zip", ) if err != nil { return to.ErrorResult(errors.TranslateError(err, map[string]string{ "operation": "DownloadActionArtifact", "owner": owner, "repo": repo, "artifact_id": strconv.FormatInt(artifactID, 10), })) } if outputPath == "" { home, _ := os.UserHomeDir() if home == "" { home = os.TempDir() } var artifactName string if info, ok := artifactInfo.(map[string]any); ok { if name, ok := info["name"].(string); ok { artifactName = name } } if artifactName == "" { artifactName = fmt.Sprintf("artifact-%d", artifactID) } outputPath = filepath.Join(home, ".gitea-mcp", "artifacts", owner, repo, fmt.Sprintf("%s.zip", artifactName)) } if err := os.MkdirAll(filepath.Dir(outputPath), 0o700); err != nil { return to.ErrorResult(errors.TranslateError(err, map[string]string{ "operation": "DownloadActionArtifact", "action": "create_output_dir", })) } if err := os.WriteFile(outputPath, artifactBytes, 0o600); err != nil { return to.ErrorResult(errors.TranslateError(err, map[string]string{ "operation": "DownloadActionArtifact", "action": "write_file", })) } var artifactName string if info, ok := artifactInfo.(map[string]any); ok { if name, ok := info["name"].(string); ok { artifactName = name } } return to.TextResult(map[string]any{ "artifact_id": artifactID, "name": artifactName, "path": outputPath, "size_in_bytes": len(artifactBytes), "message": "artifact downloaded successfully", }) } func slimActionArtifact(raw any) any { if m, ok := raw.(map[string]any); ok { return pick(m, "id", "name", "size_in_bytes", "download_url", "run_id", "created_at", "expires_at") } return raw } func slimActionArtifacts(raw any) any { return slimPaginated(raw, func(m map[string]any) map[string]any { return pick(m, "id", "name", "size_in_bytes", "download_url", "run_id", "created_at", "expires_at") }) } func formatBytes(bytes int64) string { const ( KB = 1024 MB = 1024 * KB GB = 1024 * MB ) switch { case bytes >= GB: return fmt.Sprintf("%.2f GB", float64(bytes)/GB) case bytes >= MB: return fmt.Sprintf("%.2f MB", float64(bytes)/MB) case bytes >= KB: return fmt.Sprintf("%.2f KB", float64(bytes)/KB) default: return fmt.Sprintf("%d B", bytes) } }