From 94c7455c6b2125b93c02d7f6591d31daa822cfa9 Mon Sep 17 00:00:00 2001 From: Matt Holloway Date: Tue, 16 Dec 2025 16:20:47 +0000 Subject: [PATCH] add ff saupport and consolidated actions toolsets --- README.md | 43 ++ cmd/github-mcp-server/main.go | 12 +- pkg/github/actions.go | 1001 ++++++++++++++++++++++++++- pkg/github/tools.go | 5 + pkg/github/tools_validation_test.go | 11 +- 5 files changed, 1053 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 0e96526f7..8c37ab1a7 100644 --- a/README.md +++ b/README.md @@ -490,6 +490,40 @@ The following sets of tools are available: Actions +- **actions_get** - Get details of GitHub Actions resources (workflows, workflow runs, jobs, and artifacts) + - `method`: The method to execute (string, required) + - `owner`: Repository owner (string, required) + - `repo`: Repository name (string, required) + - `resource_id`: The unique identifier of the resource. This will vary based on the "method" provided, so ensure you provide the correct ID: + - Provide a workflow ID or workflow file name (e.g. ci.yaml) for 'get_workflow' method. + - Provide a workflow run ID for 'get_workflow_run', 'get_workflow_run_usage', and 'get_workflow_run_logs_url' methods. + - Provide an artifact ID for 'download_workflow_run_artifact' method. + - Provide a job ID for 'get_workflow_job' method. + (string, required) + +- **actions_list** - List GitHub Actions workflows in a repository + - `method`: The action to perform (string, required) + - `owner`: Repository owner (string, required) + - `page`: Page number for pagination (default: 1) (number, optional) + - `per_page`: Results per page for pagination (default: 30, max: 100) (number, optional) + - `repo`: Repository name (string, required) + - `resource_id`: The unique identifier of the resource. This will vary based on the "method" provided, so ensure you provide the correct ID: + - Do not provide any resource ID for 'list_workflows' method. + - Provide a workflow ID or workflow file name (e.g. ci.yaml) for 'list_workflow_runs' method. + - Provide a workflow run ID for 'list_workflow_jobs' and 'list_workflow_run_artifacts' methods. + (string, optional) + - `workflow_jobs_filter`: Filters for workflow jobs. **ONLY** used when method is 'list_workflow_jobs' (object, optional) + - `workflow_runs_filter`: Filters for workflow runs. **ONLY** used when method is 'list_workflow_runs' (object, optional) + +- **actions_run_trigger** - Trigger GitHub Actions workflow actions + - `inputs`: Inputs the workflow accepts. Only used for 'run_workflow' method. (object, optional) + - `method`: The method to execute (string, required) + - `owner`: Repository owner (string, required) + - `ref`: The git reference for the workflow. The reference can be a branch or tag name. Required for 'run_workflow' method. (string, optional) + - `repo`: Repository name (string, required) + - `run_id`: The ID of the workflow run. Required for all methods except 'run_workflow'. (number, optional) + - `workflow_id`: The workflow ID (numeric) or workflow file name (e.g., main.yml, ci.yaml). Required for 'run_workflow' method. (string, optional) + - **cancel_workflow_run** - Cancel workflow run - `owner`: Repository owner (string, required) - `repo`: Repository name (string, required) @@ -514,6 +548,15 @@ The following sets of tools are available: - `run_id`: Workflow run ID (required when using failed_only) (number, optional) - `tail_lines`: Number of lines to return from the end of the log (number, optional) +- **get_job_logs** - Get GitHub Actions workflow job logs + - `failed_only`: When true, gets logs for all failed jobs in the workflow run specified by run_id. Requires run_id to be provided. (boolean, optional) + - `job_id`: The unique identifier of the workflow job. Required when getting logs for a single job. (number, optional) + - `owner`: Repository owner (string, required) + - `repo`: Repository name (string, required) + - `return_content`: Returns actual log content instead of URLs (boolean, optional) + - `run_id`: The unique identifier of the workflow run. Required when failed_only is true to get logs for all failed jobs in the run. (number, optional) + - `tail_lines`: Number of lines to return from the end of the log (number, optional) + - **get_workflow_run** - Get workflow run - `owner`: Repository owner (string, required) - `repo`: Repository name (string, required) diff --git a/cmd/github-mcp-server/main.go b/cmd/github-mcp-server/main.go index 034b0e238..cfb68be4e 100644 --- a/cmd/github-mcp-server/main.go +++ b/cmd/github-mcp-server/main.go @@ -54,14 +54,18 @@ var ( // Parse tools (similar to toolsets) var enabledTools []string - if err := viper.UnmarshalKey("tools", &enabledTools); err != nil { - return fmt.Errorf("failed to unmarshal tools: %w", err) + if viper.IsSet("tools") { + if err := viper.UnmarshalKey("tools", &enabledTools); err != nil { + return fmt.Errorf("failed to unmarshal tools: %w", err) + } } // Parse enabled features (similar to toolsets) var enabledFeatures []string - if err := viper.UnmarshalKey("features", &enabledFeatures); err != nil { - return fmt.Errorf("failed to unmarshal features: %w", err) + if viper.IsSet("features") { + if err := viper.UnmarshalKey("features", &enabledFeatures); err != nil { + return fmt.Errorf("failed to unmarshal features: %w", err) + } } ttl := viper.GetDuration("repo-access-cache-ttl") diff --git a/pkg/github/actions.go b/pkg/github/actions.go index 7b43f69ce..541547635 100644 --- a/pkg/github/actions.go +++ b/pkg/github/actions.go @@ -3,6 +3,7 @@ package github import ( "context" "encoding/json" + "errors" "fmt" "net/http" "strconv" @@ -24,9 +25,32 @@ const ( DescriptionRepositoryName = "Repository name" ) +// FeatureFlagConsolidatedActions is the feature flag that disables individual actions tools +// in favor of the consolidated actions tools. +const FeatureFlagConsolidatedActions = "remote_mcp_consolidated_actions" + +// Method constants for consolidated actions tools +const ( + actionsMethodListWorkflows = "list_workflows" + actionsMethodListWorkflowRuns = "list_workflow_runs" + actionsMethodListWorkflowJobs = "list_workflow_jobs" + actionsMethodListWorkflowArtifacts = "list_workflow_run_artifacts" + actionsMethodGetWorkflow = "get_workflow" + actionsMethodGetWorkflowRun = "get_workflow_run" + actionsMethodGetWorkflowJob = "get_workflow_job" + actionsMethodGetWorkflowRunUsage = "get_workflow_run_usage" + actionsMethodGetWorkflowRunLogsURL = "get_workflow_run_logs_url" + actionsMethodDownloadWorkflowArtifact = "download_workflow_run_artifact" + actionsMethodRunWorkflow = "run_workflow" + actionsMethodRerunWorkflowRun = "rerun_workflow_run" + actionsMethodRerunFailedJobs = "rerun_failed_jobs" + actionsMethodCancelWorkflowRun = "cancel_workflow_run" + actionsMethodDeleteWorkflowRunLogs = "delete_workflow_run_logs" +) + // ListWorkflows creates a tool to list workflows in a repository func ListWorkflows(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + tool := NewTool( ToolsetMetadataActions, mcp.Tool{ Name: "list_workflows", @@ -93,11 +117,13 @@ func ListWorkflows(t translations.TranslationHelperFunc) inventory.ServerTool { } }, ) + tool.FeatureFlagDisable = FeatureFlagConsolidatedActions + return tool } // ListWorkflowRuns creates a tool to list workflow runs for a specific workflow func ListWorkflowRuns(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + tool := NewTool( ToolsetMetadataActions, mcp.Tool{ Name: "list_workflow_runs", @@ -247,11 +273,13 @@ func ListWorkflowRuns(t translations.TranslationHelperFunc) inventory.ServerTool } }, ) + tool.FeatureFlagDisable = FeatureFlagConsolidatedActions + return tool } // RunWorkflow creates a tool to run an Actions workflow func RunWorkflow(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + tool := NewTool( ToolsetMetadataActions, mcp.Tool{ Name: "run_workflow", @@ -359,11 +387,13 @@ func RunWorkflow(t translations.TranslationHelperFunc) inventory.ServerTool { } }, ) + tool.FeatureFlagDisable = FeatureFlagConsolidatedActions + return tool } // GetWorkflowRun creates a tool to get details of a specific workflow run func GetWorkflowRun(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + tool := NewTool( ToolsetMetadataActions, mcp.Tool{ Name: "get_workflow_run", @@ -427,11 +457,13 @@ func GetWorkflowRun(t translations.TranslationHelperFunc) inventory.ServerTool { } }, ) + tool.FeatureFlagDisable = FeatureFlagConsolidatedActions + return tool } // GetWorkflowRunLogs creates a tool to download logs for a specific workflow run func GetWorkflowRunLogs(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + tool := NewTool( ToolsetMetadataActions, mcp.Tool{ Name: "get_workflow_run_logs", @@ -505,11 +537,13 @@ func GetWorkflowRunLogs(t translations.TranslationHelperFunc) inventory.ServerTo } }, ) + tool.FeatureFlagDisable = FeatureFlagConsolidatedActions + return tool } // ListWorkflowJobs creates a tool to list jobs for a specific workflow run func ListWorkflowJobs(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + tool := NewTool( ToolsetMetadataActions, mcp.Tool{ Name: "list_workflow_jobs", @@ -605,11 +639,13 @@ func ListWorkflowJobs(t translations.TranslationHelperFunc) inventory.ServerTool } }, ) + tool.FeatureFlagDisable = FeatureFlagConsolidatedActions + return tool } // GetJobLogs creates a tool to download logs for a specific workflow job or efficiently get all failed job logs for a workflow run func GetJobLogs(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + tool := NewTool( ToolsetMetadataActions, mcp.Tool{ Name: "get_job_logs", @@ -716,6 +752,8 @@ func GetJobLogs(t translations.TranslationHelperFunc) inventory.ServerTool { } }, ) + tool.FeatureFlagDisable = FeatureFlagConsolidatedActions + return tool } // handleFailedJobLogs gets logs for all failed jobs in a workflow run @@ -874,7 +912,7 @@ func downloadLogContent(ctx context.Context, logURL string, tailLines int, maxLi // RerunWorkflowRun creates a tool to re-run an entire workflow run func RerunWorkflowRun(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + tool := NewTool( ToolsetMetadataActions, mcp.Tool{ Name: "rerun_workflow_run", @@ -945,11 +983,13 @@ func RerunWorkflowRun(t translations.TranslationHelperFunc) inventory.ServerTool } }, ) + tool.FeatureFlagDisable = FeatureFlagConsolidatedActions + return tool } // RerunFailedJobs creates a tool to re-run only the failed jobs in a workflow run func RerunFailedJobs(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + tool := NewTool( ToolsetMetadataActions, mcp.Tool{ Name: "rerun_failed_jobs", @@ -1020,11 +1060,13 @@ func RerunFailedJobs(t translations.TranslationHelperFunc) inventory.ServerTool } }, ) + tool.FeatureFlagDisable = FeatureFlagConsolidatedActions + return tool } // CancelWorkflowRun creates a tool to cancel a workflow run func CancelWorkflowRun(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + tool := NewTool( ToolsetMetadataActions, mcp.Tool{ Name: "cancel_workflow_run", @@ -1097,11 +1139,13 @@ func CancelWorkflowRun(t translations.TranslationHelperFunc) inventory.ServerToo } }, ) + tool.FeatureFlagDisable = FeatureFlagConsolidatedActions + return tool } // ListWorkflowRunArtifacts creates a tool to list artifacts for a workflow run func ListWorkflowRunArtifacts(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + tool := NewTool( ToolsetMetadataActions, mcp.Tool{ Name: "list_workflow_run_artifacts", @@ -1177,11 +1221,13 @@ func ListWorkflowRunArtifacts(t translations.TranslationHelperFunc) inventory.Se } }, ) + tool.FeatureFlagDisable = FeatureFlagConsolidatedActions + return tool } // DownloadWorkflowRunArtifact creates a tool to download a workflow run artifact func DownloadWorkflowRunArtifact(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + tool := NewTool( ToolsetMetadataActions, mcp.Tool{ Name: "download_workflow_run_artifact", @@ -1254,11 +1300,13 @@ func DownloadWorkflowRunArtifact(t translations.TranslationHelperFunc) inventory } }, ) + tool.FeatureFlagDisable = FeatureFlagConsolidatedActions + return tool } // DeleteWorkflowRunLogs creates a tool to delete logs for a workflow run func DeleteWorkflowRunLogs(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + tool := NewTool( ToolsetMetadataActions, mcp.Tool{ Name: "delete_workflow_run_logs", @@ -1330,11 +1378,13 @@ func DeleteWorkflowRunLogs(t translations.TranslationHelperFunc) inventory.Serve } }, ) + tool.FeatureFlagDisable = FeatureFlagConsolidatedActions + return tool } // GetWorkflowRunUsage creates a tool to get usage metrics for a workflow run func GetWorkflowRunUsage(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + tool := NewTool( ToolsetMetadataActions, mcp.Tool{ Name: "get_workflow_run_usage", @@ -1398,4 +1448,927 @@ func GetWorkflowRunUsage(t translations.TranslationHelperFunc) inventory.ServerT } }, ) + tool.FeatureFlagDisable = FeatureFlagConsolidatedActions + return tool +} + +// ActionsList returns the tool and handler for listing GitHub Actions resources. +func ActionsList(t translations.TranslationHelperFunc) inventory.ServerTool { + tool := NewTool( + ToolsetMetadataActions, + mcp.Tool{ + Name: "actions_list", + Description: t("TOOL_ACTIONS_LIST_DESCRIPTION", + `Tools for listing GitHub Actions resources. +Use this tool to list workflows in a repository, or list workflow runs, jobs, and artifacts for a specific workflow or workflow run. +`), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_ACTIONS_LIST_USER_TITLE", "List GitHub Actions workflows in a repository"), + ReadOnlyHint: true, + }, + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "method": { + Type: "string", + Description: "The action to perform", + Enum: []any{ + actionsMethodListWorkflows, + actionsMethodListWorkflowRuns, + actionsMethodListWorkflowJobs, + actionsMethodListWorkflowArtifacts, + }, + }, + "owner": { + Type: "string", + Description: "Repository owner", + }, + "repo": { + Type: "string", + Description: "Repository name", + }, + "resource_id": { + Type: "string", + Description: `The unique identifier of the resource. This will vary based on the "method" provided, so ensure you provide the correct ID: +- Do not provide any resource ID for 'list_workflows' method. +- Provide a workflow ID or workflow file name (e.g. ci.yaml) for 'list_workflow_runs' method. +- Provide a workflow run ID for 'list_workflow_jobs' and 'list_workflow_run_artifacts' methods. +`, + }, + "workflow_runs_filter": { + Type: "object", + Description: "Filters for workflow runs. **ONLY** used when method is 'list_workflow_runs'", + Properties: map[string]*jsonschema.Schema{ + "actor": { + Type: "string", + Description: "Filter to a specific GitHub user's workflow runs.", + }, + "branch": { + Type: "string", + Description: "Filter workflow runs to a specific Git branch. Use the name of the branch.", + }, + "event": { + Type: "string", + Description: "Filter workflow runs to a specific event type", + Enum: []any{ + "branch_protection_rule", + "check_run", + "check_suite", + "create", + "delete", + "deployment", + "deployment_status", + "discussion", + "discussion_comment", + "fork", + "gollum", + "issue_comment", + "issues", + "label", + "merge_group", + "milestone", + "page_build", + "public", + "pull_request", + "pull_request_review", + "pull_request_review_comment", + "pull_request_target", + "push", + "registry_package", + "release", + "repository_dispatch", + "schedule", + "status", + "watch", + "workflow_call", + "workflow_dispatch", + "workflow_run", + }, + }, + "status": { + Type: "string", + Description: "Filter workflow runs to only runs with a specific status", + Enum: []any{"queued", "in_progress", "completed", "requested", "waiting"}, + }, + }, + }, + "workflow_jobs_filter": { + Type: "object", + Description: "Filters for workflow jobs. **ONLY** used when method is 'list_workflow_jobs'", + Properties: map[string]*jsonschema.Schema{ + "filter": { + Type: "string", + Description: "Filters jobs by their completed_at timestamp", + Enum: []any{"latest", "all"}, + }, + }, + }, + "page": { + Type: "number", + Description: "Page number for pagination (default: 1)", + Minimum: jsonschema.Ptr(1.0), + }, + "per_page": { + Type: "number", + Description: "Results per page for pagination (default: 30, max: 100)", + Minimum: jsonschema.Ptr(1.0), + Maximum: jsonschema.Ptr(100.0), + }, + }, + Required: []string{"method", "owner", "repo"}, + }, + }, + func(deps ToolDependencies) mcp.ToolHandlerFor[map[string]any, any] { + return func(ctx context.Context, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + owner, err := RequiredParam[string](args, "owner") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + repo, err := RequiredParam[string](args, "repo") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + method, err := RequiredParam[string](args, "method") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + resourceID, err := OptionalParam[string](args, "resource_id") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + pagination, err := OptionalPaginationParams(args) + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + client, err := deps.GetClient(ctx) + if err != nil { + return nil, nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + + var resourceIDInt int64 + var parseErr error + switch method { + case actionsMethodListWorkflows: + // Do nothing, no resource ID needed + default: + if resourceID == "" { + return utils.NewToolResultError(fmt.Sprintf("missing required parameter for method %s: resource_id", method)), nil, nil + } + + // For list_workflow_runs, resource_id could be a filename or numeric ID + // For other actions, resource ID must be an integer + if method != actionsMethodListWorkflowRuns { + resourceIDInt, parseErr = strconv.ParseInt(resourceID, 10, 64) + if parseErr != nil { + return utils.NewToolResultError(fmt.Sprintf("invalid resource_id, must be an integer for method %s: %v", method, parseErr)), nil, nil + } + } + } + + switch method { + case actionsMethodListWorkflows: + return listWorkflows(ctx, client, owner, repo, pagination) + case actionsMethodListWorkflowRuns: + return listWorkflowRuns(ctx, client, args, owner, repo, resourceID, pagination) + case actionsMethodListWorkflowJobs: + return listWorkflowJobs(ctx, client, args, owner, repo, resourceIDInt, pagination) + case actionsMethodListWorkflowArtifacts: + return listWorkflowArtifacts(ctx, client, owner, repo, resourceIDInt, pagination) + default: + return utils.NewToolResultError(fmt.Sprintf("unknown method: %s", method)), nil, nil + } + } + }, + ) + tool.FeatureFlagEnable = FeatureFlagConsolidatedActions + return tool +} + +// ActionsGet returns the tool and handler for getting GitHub Actions resources. +func ActionsGet(t translations.TranslationHelperFunc) inventory.ServerTool { + tool := NewTool( + ToolsetMetadataActions, + mcp.Tool{ + Name: "actions_get", + Description: t("TOOL_ACTIONS_GET_DESCRIPTION", `Get details about specific GitHub Actions resources. +Use this tool to get details about individual workflows, workflow runs, jobs, and artifacts by their unique IDs. +`), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_ACTIONS_GET_USER_TITLE", "Get details of GitHub Actions resources (workflows, workflow runs, jobs, and artifacts)"), + ReadOnlyHint: true, + }, + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "method": { + Type: "string", + Description: "The method to execute", + Enum: []any{ + actionsMethodGetWorkflow, + actionsMethodGetWorkflowRun, + actionsMethodGetWorkflowJob, + actionsMethodDownloadWorkflowArtifact, + actionsMethodGetWorkflowRunUsage, + actionsMethodGetWorkflowRunLogsURL, + }, + }, + "owner": { + Type: "string", + Description: "Repository owner", + }, + "repo": { + Type: "string", + Description: "Repository name", + }, + "resource_id": { + Type: "string", + Description: `The unique identifier of the resource. This will vary based on the "method" provided, so ensure you provide the correct ID: +- Provide a workflow ID or workflow file name (e.g. ci.yaml) for 'get_workflow' method. +- Provide a workflow run ID for 'get_workflow_run', 'get_workflow_run_usage', and 'get_workflow_run_logs_url' methods. +- Provide an artifact ID for 'download_workflow_run_artifact' method. +- Provide a job ID for 'get_workflow_job' method. +`, + }, + }, + Required: []string{"method", "owner", "repo", "resource_id"}, + }, + }, + func(deps ToolDependencies) mcp.ToolHandlerFor[map[string]any, any] { + return func(ctx context.Context, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + owner, err := RequiredParam[string](args, "owner") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + repo, err := RequiredParam[string](args, "repo") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + method, err := RequiredParam[string](args, "method") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + resourceID, err := RequiredParam[string](args, "resource_id") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + client, err := deps.GetClient(ctx) + if err != nil { + return nil, nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + + var resourceIDInt int64 + var parseErr error + switch method { + case actionsMethodGetWorkflow: + // Do nothing, we accept both a string workflow ID or filename + default: + // For other methods, resource ID must be an integer + resourceIDInt, parseErr = strconv.ParseInt(resourceID, 10, 64) + if parseErr != nil { + return utils.NewToolResultError(fmt.Sprintf("invalid resource_id, must be an integer for method %s: %v", method, parseErr)), nil, nil + } + } + + switch method { + case actionsMethodGetWorkflow: + return getWorkflow(ctx, client, owner, repo, resourceID) + case actionsMethodGetWorkflowRun: + return getWorkflowRun(ctx, client, owner, repo, resourceIDInt) + case actionsMethodGetWorkflowJob: + return getWorkflowJob(ctx, client, owner, repo, resourceIDInt) + case actionsMethodDownloadWorkflowArtifact: + return downloadWorkflowArtifact(ctx, client, owner, repo, resourceIDInt) + case actionsMethodGetWorkflowRunUsage: + return getWorkflowRunUsage(ctx, client, owner, repo, resourceIDInt) + case actionsMethodGetWorkflowRunLogsURL: + return getWorkflowRunLogsURL(ctx, client, owner, repo, resourceIDInt) + default: + return utils.NewToolResultError(fmt.Sprintf("unknown method: %s", method)), nil, nil + } + } + }, + ) + tool.FeatureFlagEnable = FeatureFlagConsolidatedActions + return tool +} + +// ActionsRunTrigger returns the tool and handler for triggering GitHub Actions workflows. +func ActionsRunTrigger(t translations.TranslationHelperFunc) inventory.ServerTool { + tool := NewTool( + ToolsetMetadataActions, + mcp.Tool{ + Name: "actions_run_trigger", + Description: t("TOOL_ACTIONS_RUN_TRIGGER_DESCRIPTION", "Trigger GitHub Actions workflow operations, including running, re-running, cancelling workflow runs, and deleting workflow run logs."), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_ACTIONS_RUN_TRIGGER_USER_TITLE", "Trigger GitHub Actions workflow actions"), + ReadOnlyHint: false, + DestructiveHint: jsonschema.Ptr(true), + }, + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "method": { + Type: "string", + Description: "The method to execute", + Enum: []any{ + actionsMethodRunWorkflow, + actionsMethodRerunWorkflowRun, + actionsMethodRerunFailedJobs, + actionsMethodCancelWorkflowRun, + actionsMethodDeleteWorkflowRunLogs, + }, + }, + "owner": { + Type: "string", + Description: "Repository owner", + }, + "repo": { + Type: "string", + Description: "Repository name", + }, + "workflow_id": { + Type: "string", + Description: "The workflow ID (numeric) or workflow file name (e.g., main.yml, ci.yaml). Required for 'run_workflow' method.", + }, + "ref": { + Type: "string", + Description: "The git reference for the workflow. The reference can be a branch or tag name. Required for 'run_workflow' method.", + }, + "inputs": { + Type: "object", + Description: "Inputs the workflow accepts. Only used for 'run_workflow' method.", + }, + "run_id": { + Type: "number", + Description: "The ID of the workflow run. Required for all methods except 'run_workflow'.", + }, + }, + Required: []string{"method", "owner", "repo"}, + }, + }, + func(deps ToolDependencies) mcp.ToolHandlerFor[map[string]any, any] { + return func(ctx context.Context, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + owner, err := RequiredParam[string](args, "owner") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + repo, err := RequiredParam[string](args, "repo") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + method, err := RequiredParam[string](args, "method") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + // Get optional parameters + workflowID, _ := OptionalParam[string](args, "workflow_id") + ref, _ := OptionalParam[string](args, "ref") + runID, _ := OptionalIntParam(args, "run_id") + + // Get optional inputs parameter + var inputs map[string]interface{} + if requestInputs, ok := args["inputs"]; ok { + if inputsMap, ok := requestInputs.(map[string]interface{}); ok { + inputs = inputsMap + } + } + + // Validate required parameters based on action type + if method == actionsMethodRunWorkflow { + if workflowID == "" { + return utils.NewToolResultError("workflow_id is required for run_workflow action"), nil, nil + } + if ref == "" { + return utils.NewToolResultError("ref is required for run_workflow action"), nil, nil + } + } else if runID == 0 { + return utils.NewToolResultError("missing required parameter: run_id"), nil, nil + } + + client, err := deps.GetClient(ctx) + if err != nil { + return nil, nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + + switch method { + case actionsMethodRunWorkflow: + return runWorkflow(ctx, client, owner, repo, workflowID, ref, inputs) + case actionsMethodRerunWorkflowRun: + return rerunWorkflowRun(ctx, client, owner, repo, int64(runID)) + case actionsMethodRerunFailedJobs: + return rerunFailedJobs(ctx, client, owner, repo, int64(runID)) + case actionsMethodCancelWorkflowRun: + return cancelWorkflowRun(ctx, client, owner, repo, int64(runID)) + case actionsMethodDeleteWorkflowRunLogs: + return deleteWorkflowRunLogs(ctx, client, owner, repo, int64(runID)) + default: + return utils.NewToolResultError(fmt.Sprintf("unknown method: %s", method)), nil, nil + } + } + }, + ) + tool.FeatureFlagEnable = FeatureFlagConsolidatedActions + return tool +} + +// ActionsGetJobLogs returns the tool and handler for getting workflow job logs. +func ActionsGetJobLogs(t translations.TranslationHelperFunc) inventory.ServerTool { + tool := NewTool( + ToolsetMetadataActions, + mcp.Tool{ + Name: "get_job_logs", + Description: t("TOOL_GET_JOB_LOGS_CONSOLIDATED_DESCRIPTION", `Get logs for GitHub Actions workflow jobs. +Use this tool to retrieve logs for a specific job or all failed jobs in a workflow run. +For single job logs, provide job_id. For all failed jobs in a run, provide run_id with failed_only=true. +`), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_GET_JOB_LOGS_CONSOLIDATED_USER_TITLE", "Get GitHub Actions workflow job logs"), + ReadOnlyHint: true, + }, + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "owner": { + Type: "string", + Description: "Repository owner", + }, + "repo": { + Type: "string", + Description: "Repository name", + }, + "job_id": { + Type: "number", + Description: "The unique identifier of the workflow job. Required when getting logs for a single job.", + }, + "run_id": { + Type: "number", + Description: "The unique identifier of the workflow run. Required when failed_only is true to get logs for all failed jobs in the run.", + }, + "failed_only": { + Type: "boolean", + Description: "When true, gets logs for all failed jobs in the workflow run specified by run_id. Requires run_id to be provided.", + }, + "return_content": { + Type: "boolean", + Description: "Returns actual log content instead of URLs", + }, + "tail_lines": { + Type: "number", + Description: "Number of lines to return from the end of the log", + Default: json.RawMessage(`500`), + }, + }, + Required: []string{"owner", "repo"}, + }, + }, + func(deps ToolDependencies) mcp.ToolHandlerFor[map[string]any, any] { + return func(ctx context.Context, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + owner, err := RequiredParam[string](args, "owner") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + repo, err := RequiredParam[string](args, "repo") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + jobID, err := OptionalIntParam(args, "job_id") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + runID, err := OptionalIntParam(args, "run_id") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + failedOnly, err := OptionalParam[bool](args, "failed_only") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + returnContent, err := OptionalParam[bool](args, "return_content") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + tailLines, err := OptionalIntParam(args, "tail_lines") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + // Default to 500 lines if not specified + if tailLines == 0 { + tailLines = 500 + } + + client, err := deps.GetClient(ctx) + if err != nil { + return nil, nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + + // Validate parameters + if failedOnly && runID == 0 { + return utils.NewToolResultError("run_id is required when failed_only is true"), nil, nil + } + if !failedOnly && jobID == 0 { + return utils.NewToolResultError("job_id is required when failed_only is false"), nil, nil + } + + if failedOnly && runID > 0 { + // Handle failed-only mode: get logs for all failed jobs in the workflow run + return handleFailedJobLogs(ctx, client, owner, repo, int64(runID), returnContent, tailLines, deps.GetContentWindowSize()) + } else if jobID > 0 { + // Handle single job mode + return handleSingleJobLogs(ctx, client, owner, repo, int64(jobID), returnContent, tailLines, deps.GetContentWindowSize()) + } + + return utils.NewToolResultError("Either job_id must be provided for single job logs, or run_id with failed_only=true for failed job logs"), nil, nil + } + }, + ) + tool.FeatureFlagEnable = FeatureFlagConsolidatedActions + return tool +} + +// Helper functions for consolidated actions tools + +func getWorkflow(ctx context.Context, client *github.Client, owner, repo, resourceID string) (*mcp.CallToolResult, any, error) { + var workflow *github.Workflow + var resp *github.Response + var err error + + if workflowIDInt, parseErr := strconv.ParseInt(resourceID, 10, 64); parseErr == nil { + workflow, resp, err = client.Actions.GetWorkflowByID(ctx, owner, repo, workflowIDInt) + } else { + workflow, resp, err = client.Actions.GetWorkflowByFileName(ctx, owner, repo, resourceID) + } + + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to get workflow", resp, err), nil, nil + } + + defer func() { _ = resp.Body.Close() }() + r, err := json.Marshal(workflow) + if err != nil { + return nil, nil, fmt.Errorf("failed to marshal workflow: %w", err) + } + + return utils.NewToolResultText(string(r)), nil, nil +} + +func getWorkflowRun(ctx context.Context, client *github.Client, owner, repo string, resourceID int64) (*mcp.CallToolResult, any, error) { + workflowRun, resp, err := client.Actions.GetWorkflowRunByID(ctx, owner, repo, resourceID) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to get workflow run", resp, err), nil, nil + } + defer func() { _ = resp.Body.Close() }() + r, err := json.Marshal(workflowRun) + if err != nil { + return nil, nil, fmt.Errorf("failed to marshal workflow run: %w", err) + } + return utils.NewToolResultText(string(r)), nil, nil +} + +func getWorkflowJob(ctx context.Context, client *github.Client, owner, repo string, resourceID int64) (*mcp.CallToolResult, any, error) { + workflowJob, resp, err := client.Actions.GetWorkflowJobByID(ctx, owner, repo, resourceID) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to get workflow job", resp, err), nil, nil + } + defer func() { _ = resp.Body.Close() }() + r, err := json.Marshal(workflowJob) + if err != nil { + return nil, nil, fmt.Errorf("failed to marshal workflow job: %w", err) + } + return utils.NewToolResultText(string(r)), nil, nil +} + +func listWorkflows(ctx context.Context, client *github.Client, owner, repo string, pagination PaginationParams) (*mcp.CallToolResult, any, error) { + opts := &github.ListOptions{ + PerPage: pagination.PerPage, + Page: pagination.Page, + } + + workflows, resp, err := client.Actions.ListWorkflows(ctx, owner, repo, opts) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to list workflows", resp, err), nil, nil + } + defer func() { _ = resp.Body.Close() }() + + r, err := json.Marshal(workflows) + if err != nil { + return nil, nil, fmt.Errorf("failed to marshal workflows: %w", err) + } + + return utils.NewToolResultText(string(r)), nil, nil +} + +func listWorkflowRuns(ctx context.Context, client *github.Client, args map[string]any, owner, repo, resourceID string, pagination PaginationParams) (*mcp.CallToolResult, any, error) { + filterArgs, err := OptionalParam[map[string]any](args, "workflow_runs_filter") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + filterArgsTyped := make(map[string]string) + for k, v := range filterArgs { + if strVal, ok := v.(string); ok { + filterArgsTyped[k] = strVal + } else { + filterArgsTyped[k] = "" + } + } + + listWorkflowRunsOptions := &github.ListWorkflowRunsOptions{ + Actor: filterArgsTyped["actor"], + Branch: filterArgsTyped["branch"], + Event: filterArgsTyped["event"], + Status: filterArgsTyped["status"], + ListOptions: github.ListOptions{ + Page: pagination.Page, + PerPage: pagination.PerPage, + }, + } + + var workflowRuns *github.WorkflowRuns + var resp *github.Response + + if workflowIDInt, parseErr := strconv.ParseInt(resourceID, 10, 64); parseErr == nil { + workflowRuns, resp, err = client.Actions.ListWorkflowRunsByID(ctx, owner, repo, workflowIDInt, listWorkflowRunsOptions) + } else { + workflowRuns, resp, err = client.Actions.ListWorkflowRunsByFileName(ctx, owner, repo, resourceID, listWorkflowRunsOptions) + } + + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to list workflow runs", resp, err), nil, nil + } + + defer func() { _ = resp.Body.Close() }() + r, err := json.Marshal(workflowRuns) + if err != nil { + return nil, nil, fmt.Errorf("failed to marshal workflow runs: %w", err) + } + + return utils.NewToolResultText(string(r)), nil, nil +} + +func listWorkflowJobs(ctx context.Context, client *github.Client, args map[string]any, owner, repo string, resourceID int64, pagination PaginationParams) (*mcp.CallToolResult, any, error) { + filterArgs, err := OptionalParam[map[string]any](args, "workflow_jobs_filter") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + filterArgsTyped := make(map[string]string) + for k, v := range filterArgs { + if strVal, ok := v.(string); ok { + filterArgsTyped[k] = strVal + } else { + filterArgsTyped[k] = "" + } + } + + workflowJobs, resp, err := client.Actions.ListWorkflowJobs(ctx, owner, repo, resourceID, &github.ListWorkflowJobsOptions{ + Filter: filterArgsTyped["filter"], + ListOptions: github.ListOptions{ + Page: pagination.Page, + PerPage: pagination.PerPage, + }, + }) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to list workflow jobs", resp, err), nil, nil + } + + response := map[string]any{ + "jobs": workflowJobs, + } + + defer func() { _ = resp.Body.Close() }() + r, err := json.Marshal(response) + if err != nil { + return nil, nil, fmt.Errorf("failed to marshal workflow jobs: %w", err) + } + + return utils.NewToolResultText(string(r)), nil, nil +} + +func listWorkflowArtifacts(ctx context.Context, client *github.Client, owner, repo string, resourceID int64, pagination PaginationParams) (*mcp.CallToolResult, any, error) { + opts := &github.ListOptions{ + PerPage: pagination.PerPage, + Page: pagination.Page, + } + + artifacts, resp, err := client.Actions.ListWorkflowRunArtifacts(ctx, owner, repo, resourceID, opts) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to list workflow run artifacts", resp, err), nil, nil + } + defer func() { _ = resp.Body.Close() }() + + r, err := json.Marshal(artifacts) + if err != nil { + return nil, nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return utils.NewToolResultText(string(r)), nil, nil +} + +func downloadWorkflowArtifact(ctx context.Context, client *github.Client, owner, repo string, resourceID int64) (*mcp.CallToolResult, any, error) { + // Get the download URL for the artifact + url, resp, err := client.Actions.DownloadArtifact(ctx, owner, repo, resourceID, 1) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to get artifact download URL", resp, err), nil, nil + } + defer func() { _ = resp.Body.Close() }() + + // Create response with the download URL and information + result := map[string]any{ + "download_url": url.String(), + "message": "Artifact is available for download", + "note": "The download_url provides a download link for the artifact as a ZIP archive. The link is temporary and expires after a short time.", + "artifact_id": resourceID, + } + + r, err := json.Marshal(result) + if err != nil { + return nil, nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return utils.NewToolResultText(string(r)), nil, nil +} + +func getWorkflowRunLogsURL(ctx context.Context, client *github.Client, owner, repo string, runID int64) (*mcp.CallToolResult, any, error) { + // Get the download URL for the logs + url, resp, err := client.Actions.GetWorkflowRunLogs(ctx, owner, repo, runID, 1) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to get workflow run logs", resp, err), nil, nil + } + defer func() { _ = resp.Body.Close() }() + + // Create response with the logs URL and information + result := map[string]any{ + "logs_url": url.String(), + "message": "Workflow run logs are available for download", + "note": "The logs_url provides a download link for the complete workflow run logs as a ZIP archive. You can download this archive to extract and examine individual job logs.", + "warning": "This downloads ALL logs as a ZIP file which can be large and expensive. For debugging failed jobs, consider using get_job_logs with failed_only=true and run_id instead.", + "optimization_tip": "Use: get_job_logs with parameters {run_id: " + fmt.Sprintf("%d", runID) + ", failed_only: true} for more efficient failed job debugging", + } + + r, err := json.Marshal(result) + if err != nil { + return nil, nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return utils.NewToolResultText(string(r)), nil, nil +} + +func getWorkflowRunUsage(ctx context.Context, client *github.Client, owner, repo string, resourceID int64) (*mcp.CallToolResult, any, error) { + usage, resp, err := client.Actions.GetWorkflowRunUsageByID(ctx, owner, repo, resourceID) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to get workflow run usage", resp, err), nil, nil + } + defer func() { _ = resp.Body.Close() }() + + r, err := json.Marshal(usage) + if err != nil { + return nil, nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return utils.NewToolResultText(string(r)), nil, nil +} + +func runWorkflow(ctx context.Context, client *github.Client, owner, repo, workflowID, ref string, inputs map[string]interface{}) (*mcp.CallToolResult, any, error) { + event := github.CreateWorkflowDispatchEventRequest{ + Ref: ref, + Inputs: inputs, + } + + var resp *github.Response + var err error + var workflowType string + + if workflowIDInt, parseErr := strconv.ParseInt(workflowID, 10, 64); parseErr == nil { + resp, err = client.Actions.CreateWorkflowDispatchEventByID(ctx, owner, repo, workflowIDInt, event) + workflowType = "workflow_id" + } else { + resp, err = client.Actions.CreateWorkflowDispatchEventByFileName(ctx, owner, repo, workflowID, event) + workflowType = "workflow_file" + } + + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to run workflow", resp, err), nil, nil + } + defer func() { _ = resp.Body.Close() }() + + result := map[string]any{ + "message": "Workflow run has been queued", + "workflow_type": workflowType, + "workflow_id": workflowID, + "ref": ref, + "inputs": inputs, + "status": resp.Status, + "status_code": resp.StatusCode, + } + + r, err := json.Marshal(result) + if err != nil { + return nil, nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return utils.NewToolResultText(string(r)), nil, nil +} + +func rerunWorkflowRun(ctx context.Context, client *github.Client, owner, repo string, runID int64) (*mcp.CallToolResult, any, error) { + resp, err := client.Actions.RerunWorkflowByID(ctx, owner, repo, runID) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to rerun workflow run", resp, err), nil, nil + } + defer func() { _ = resp.Body.Close() }() + + result := map[string]any{ + "message": "Workflow run has been queued for re-run", + "run_id": runID, + "status": resp.Status, + "status_code": resp.StatusCode, + } + + r, err := json.Marshal(result) + if err != nil { + return nil, nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return utils.NewToolResultText(string(r)), nil, nil +} + +func rerunFailedJobs(ctx context.Context, client *github.Client, owner, repo string, runID int64) (*mcp.CallToolResult, any, error) { + resp, err := client.Actions.RerunFailedJobsByID(ctx, owner, repo, runID) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to rerun failed jobs", resp, err), nil, nil + } + defer func() { _ = resp.Body.Close() }() + + result := map[string]any{ + "message": "Failed jobs have been queued for re-run", + "run_id": runID, + "status": resp.Status, + "status_code": resp.StatusCode, + } + + r, err := json.Marshal(result) + if err != nil { + return nil, nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return utils.NewToolResultText(string(r)), nil, nil +} + +func cancelWorkflowRun(ctx context.Context, client *github.Client, owner, repo string, runID int64) (*mcp.CallToolResult, any, error) { + resp, err := client.Actions.CancelWorkflowRunByID(ctx, owner, repo, runID) + if err != nil { + var acceptedErr *github.AcceptedError + if !errors.As(err, &acceptedErr) { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to cancel workflow run", resp, err), nil, nil + } + } + defer func() { _ = resp.Body.Close() }() + + result := map[string]any{ + "message": "Workflow run has been cancelled", + "run_id": runID, + "status": resp.Status, + "status_code": resp.StatusCode, + } + + r, err := json.Marshal(result) + if err != nil { + return nil, nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return utils.NewToolResultText(string(r)), nil, nil +} + +func deleteWorkflowRunLogs(ctx context.Context, client *github.Client, owner, repo string, runID int64) (*mcp.CallToolResult, any, error) { + resp, err := client.Actions.DeleteWorkflowRunLogs(ctx, owner, repo, runID) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to delete workflow run logs", resp, err), nil, nil + } + defer func() { _ = resp.Body.Close() }() + + result := map[string]any{ + "message": "Workflow run logs have been deleted", + "run_id": runID, + "status": resp.Status, + "status_code": resp.StatusCode, + } + + r, err := json.Marshal(result) + if err != nil { + return nil, nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return utils.NewToolResultText(string(r)), nil, nil } diff --git a/pkg/github/tools.go b/pkg/github/tools.go index 62d2d8664..2caa05979 100644 --- a/pkg/github/tools.go +++ b/pkg/github/tools.go @@ -214,6 +214,11 @@ func AllTools(t translations.TranslationHelperFunc) []inventory.ServerTool { RerunFailedJobs(t), CancelWorkflowRun(t), DeleteWorkflowRunLogs(t), + // Consolidated Actions tools (enabled via feature flag) + ActionsList(t), + ActionsGet(t), + ActionsRunTrigger(t), + ActionsGetJobLogs(t), // Security advisories tools ListGlobalSecurityAdvisories(t), diff --git a/pkg/github/tools_validation_test.go b/pkg/github/tools_validation_test.go index 8821eedd1..90e3c744c 100644 --- a/pkg/github/tools_validation_test.go +++ b/pkg/github/tools_validation_test.go @@ -101,6 +101,7 @@ func TestToolReadOnlyHintConsistency(t *testing.T) { func TestNoDuplicateToolNames(t *testing.T) { tools := AllTools(stubTranslation) seen := make(map[string]bool) + featureFlagged := make(map[string]bool) // get_label is intentionally in both issues and labels toolsets for conformance // with original behavior where it was registered in both @@ -108,9 +109,17 @@ func TestNoDuplicateToolNames(t *testing.T) { "get_label": true, } + // First pass: identify tools that have feature flags (mutually exclusive at runtime) + for _, tool := range tools { + if tool.FeatureFlagEnable != "" || tool.FeatureFlagDisable != "" { + featureFlagged[tool.Tool.Name] = true + } + } + for _, tool := range tools { name := tool.Tool.Name - if !allowedDuplicates[name] { + // Allow duplicates for explicitly allowed tools and feature-flagged tools + if !allowedDuplicates[name] && !featureFlagged[name] { assert.False(t, seen[name], "Duplicate tool name found: %q", name) }