diff --git a/README.md b/README.md index b9ef26a..45e34ee 100644 --- a/README.md +++ b/README.md @@ -538,6 +538,16 @@ export GITHUB_MCP_TOOL_ADD_ISSUE_COMMENT_DESCRIPTION="an alternative description - `severity`: Alert severity (string, optional) - `tool_name`: The name of the tool used for code scanning (string, optional) +### Actions + +- **run_workflow** - Trigger a workflow run + + - `owner`: Repository owner (string, required) + - `repo`: Repository name (string, required) + - `workflow_file`: Workflow ID or filename (string, required) + - `ref`: Git reference (branch or tag name) (string, required) + - `inputs`: Workflow inputs (object, optional) + ### Secret Scanning - **get_secret_scanning_alert** - Get a secret scanning alert diff --git a/pkg/github/actions.go b/pkg/github/actions.go new file mode 100644 index 0000000..f9e7b79 --- /dev/null +++ b/pkg/github/actions.go @@ -0,0 +1,97 @@ +package github + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/github/github-mcp-server/pkg/translations" + "github.com/google/go-github/v69/github" + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" +) + +// RunWorkflow creates a tool to run an Actions workflow +func RunWorkflow(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("run_workflow", + mcp.WithDescription(t("TOOL_RUN_WORKFLOW_DESCRIPTION", "Trigger a workflow run")), + mcp.WithString("owner", + mcp.Required(), + mcp.Description("The account owner of the repository. The name is not case sensitive."), + ), + mcp.WithString("repo", + mcp.Required(), + mcp.Description("Repository name"), + ), + mcp.WithString("workflow_file", + mcp.Required(), + mcp.Description("The workflow file name or ID of the workflow entity."), + ), + mcp.WithString("ref", + mcp.Required(), + mcp.Description("Git reference (branch or tag name)"), + ), + mcp.WithObject("inputs", + mcp.Description("Input keys and values configured in the workflow file."), + ), + ), + func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + owner, err := requiredParam[string](request, "owner") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + repo, err := requiredParam[string](request, "repo") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + workflowFileName, err := requiredParam[string](request, "workflow_file") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + ref, err := requiredParam[string](request, "ref") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + // Get the optional inputs parameter + var inputs map[string]any + if inputsObj, exists := request.Params.Arguments["inputs"]; exists && inputsObj != nil { + inputs, _ = inputsObj.(map[string]any) + } + + // Convert inputs to the format expected by the GitHub API + inputsMap := make(map[string]any) + for k, v := range inputs { + inputsMap[k] = v + } + + // Create the event to dispatch + event := github.CreateWorkflowDispatchEventRequest{ + Ref: ref, + Inputs: inputsMap, + } + + client, err := getClient(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + + resp, err := client.Actions.CreateWorkflowDispatchEventByFileName(ctx, owner, repo, workflowFileName, event) + if err != nil { + return nil, fmt.Errorf("failed to trigger workflow: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + result := map[string]any{ + "success": true, + "message": "Workflow triggered successfully", + } + + r, err := json.Marshal(result) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil + } +} diff --git a/pkg/github/actions_test.go b/pkg/github/actions_test.go new file mode 100644 index 0000000..b04ca35 --- /dev/null +++ b/pkg/github/actions_test.go @@ -0,0 +1,104 @@ +package github + +import ( + "context" + "encoding/json" + "net/http" + "testing" + + "github.com/github/github-mcp-server/pkg/translations" + "github.com/google/go-github/v69/github" + "github.com/migueleliasweb/go-github-mock/src/mock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_RunWorkflow(t *testing.T) { + // Verify tool definition once + mockClient := github.NewClient(nil) + tool, _ := RunWorkflow(stubGetClientFn(mockClient), translations.NullTranslationHelper) + + assert.Equal(t, "run_workflow", tool.Name) + assert.NotEmpty(t, tool.Description) + assert.Contains(t, tool.InputSchema.Properties, "owner") + assert.Contains(t, tool.InputSchema.Properties, "repo") + assert.Contains(t, tool.InputSchema.Properties, "workflow_file") + assert.Contains(t, tool.InputSchema.Properties, "ref") + assert.Contains(t, tool.InputSchema.Properties, "inputs") + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "workflow_file", "ref"}) + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]any + expectError bool + expectedErrMsg string + }{ + { + name: "successful workflow trigger", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.PostReposActionsWorkflowsDispatchesByOwnerByRepoByWorkflowId, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNoContent) + }), + ), + ), + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "workflow_file": "main.yaml", + "ref": "main", + "inputs": map[string]any{ + "input1": "value1", + "input2": "value2", + }, + }, + expectError: false, + }, + { + name: "missing required parameter", + mockedClient: mock.NewMockedHTTPClient(), + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "workflow_file": "main.yaml", + // missing ref + }, + expectError: true, + expectedErrMsg: "missing required parameter: ref", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Setup client with mock + client := github.NewClient(tc.mockedClient) + _, handler := RunWorkflow(stubGetClientFn(client), translations.NullTranslationHelper) + + // Create call request + request := createMCPRequest(tc.requestArgs) + + // Call handler + result, err := handler(context.Background(), request) + + require.NoError(t, err) + require.Equal(t, tc.expectError, result.IsError) + + // Parse the result and get the text content if no error + textContent := getTextResult(t, result) + + if tc.expectedErrMsg != "" { + assert.Equal(t, tc.expectedErrMsg, textContent.Text) + return + } + + // Unmarshal and verify the result + var response map[string]any + err = json.Unmarshal([]byte(textContent.Text), &response) + require.NoError(t, err) + assert.Equal(t, true, response["success"]) + assert.Equal(t, "Workflow triggered successfully", response["message"]) + }) + } +} diff --git a/pkg/github/tools.go b/pkg/github/tools.go index 1a4a3b4..2488ad5 100644 --- a/pkg/github/tools.go +++ b/pkg/github/tools.go @@ -73,6 +73,11 @@ func InitToolsets(passedToolsets []string, readOnly bool, getClient GetClientFn, toolsets.NewServerTool(GetCodeScanningAlert(getClient, t)), toolsets.NewServerTool(ListCodeScanningAlerts(getClient, t)), ) + actions := toolsets.NewToolset("actions", "GitHub Actions related tools"). + AddWriteTools( + toolsets.NewServerTool(RunWorkflow(getClient, t)), + ) + secretProtection := toolsets.NewToolset("secret_protection", "Secret protection related tools, such as GitHub Secret Scanning"). AddReadTools( toolsets.NewServerTool(GetSecretScanningAlert(getClient, t)), @@ -87,6 +92,7 @@ func InitToolsets(passedToolsets []string, readOnly bool, getClient GetClientFn, tsg.AddToolset(users) tsg.AddToolset(pullRequests) tsg.AddToolset(codeSecurity) + tsg.AddToolset(actions) tsg.AddToolset(secretProtection) tsg.AddToolset(experiments) // Enable the requested features