From d776940a632797a51f3fa1fe41fc111422d4057f Mon Sep 17 00:00:00 2001 From: Josh Gross Date: Mon, 14 Apr 2025 15:42:49 -0400 Subject: [PATCH 1/4] Add support for running an Actions workflow --- README.md | 10 ++++ pkg/github/actions.go | 99 +++++++++++++++++++++++++++++++++++ pkg/github/actions_test.go | 104 +++++++++++++++++++++++++++++++++++++ pkg/github/server.go | 5 ++ 4 files changed, 218 insertions(+) create mode 100644 pkg/github/actions.go create mode 100644 pkg/github/actions_test.go diff --git a/README.md b/README.md index 6bfc6ab5..d5c03959 100644 --- a/README.md +++ b/README.md @@ -437,6 +437,16 @@ export GITHUB_MCP_TOOL_ADD_ISSUE_COMMENT_DESCRIPTION="an alternative description - `state`: Alert state (string, optional) - `severity`: Alert severity (string, optional) +### Actions + +- **run_workflow** - Trigger a workflow run + + - `owner`: Repository owner (string, required) + - `repo`: Repository name (string, required) + - `workflowId`: Workflow ID or filename (string, required) + - `ref`: Git reference (branch or tag name) (string, required) + - `inputs`: Workflow inputs (object, optional) + ## Resources ### Repository Content diff --git a/pkg/github/actions.go b/pkg/github/actions.go new file mode 100644 index 00000000..fc6c4c86 --- /dev/null +++ b/pkg/github/actions.go @@ -0,0 +1,99 @@ +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("workflowId", + mcp.Required(), + mcp.Description("The ID of the workflow. You can also pass the workflow file name as a string."), + ), + 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 + } + workflowID, err := requiredParam[string](request, "workflowId") + 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) + if inputs != nil { + 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, workflowID, 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 00000000..08514b6b --- /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, "workflowId") + assert.Contains(t, tool.InputSchema.Properties, "ref") + assert.Contains(t, tool.InputSchema.Properties, "inputs") + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "workflowId", "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", + "workflowId": "workflow_id", + "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", + "workflowId": "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/server.go b/pkg/github/server.go index da916b98..3eaa53af 100644 --- a/pkg/github/server.go +++ b/pkg/github/server.go @@ -80,6 +80,11 @@ func NewServer(getClient GetClientFn, version string, readOnly bool, t translati s.AddTool(PushFiles(getClient, t)) } + // Add GitHub tools - Actions + if !readOnly { + s.AddTool(RunWorkflow(getClient, t)) + } + // Add GitHub tools - Search s.AddTool(SearchCode(getClient, t)) s.AddTool(SearchUsers(getClient, t)) From 39099ec7ec2609a4a833e90dd223c6ab446ee9c8 Mon Sep 17 00:00:00 2001 From: Josh Gross Date: Mon, 14 Apr 2025 15:55:57 -0400 Subject: [PATCH 2/4] Remove unnecessary `nil` check --- pkg/github/actions.go | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/pkg/github/actions.go b/pkg/github/actions.go index fc6c4c86..f9169d47 100644 --- a/pkg/github/actions.go +++ b/pkg/github/actions.go @@ -61,10 +61,8 @@ func RunWorkflow(getClient GetClientFn, t translations.TranslationHelperFunc) (t // Convert inputs to the format expected by the GitHub API inputsMap := make(map[string]any) - if inputs != nil { - for k, v := range inputs { - inputsMap[k] = v - } + for k, v := range inputs { + inputsMap[k] = v } // Create the event to dispatch From e89ccf6bb565921c0eb01b011464b5cf9317586e Mon Sep 17 00:00:00 2001 From: Josh Gross Date: Tue, 15 Apr 2025 18:38:13 -0400 Subject: [PATCH 3/4] Add `actions` toolset --- pkg/github/tools.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/pkg/github/tools.go b/pkg/github/tools.go index ce10c4ad..84c3c789 100644 --- a/pkg/github/tools.go +++ b/pkg/github/tools.go @@ -73,6 +73,10 @@ 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)), + ) // Keep experiments alive so the system doesn't error out when it's always enabled experiments := toolsets.NewToolset("experiments", "Experimental features that are not considered stable yet") @@ -82,6 +86,7 @@ func InitToolsets(passedToolsets []string, readOnly bool, getClient GetClientFn, tsg.AddToolset(users) tsg.AddToolset(pullRequests) tsg.AddToolset(codeSecurity) + tsg.AddToolset(actions) tsg.AddToolset(experiments) // Enable the requested features From 034621bddc7051538015b052113a38e183bbeb58 Mon Sep 17 00:00:00 2001 From: Josh Gross Date: Tue, 15 Apr 2025 18:40:59 -0400 Subject: [PATCH 4/4] Rename `workflowId` to `workflow_file` --- README.md | 2 +- pkg/github/actions.go | 8 ++++---- pkg/github/actions_test.go | 18 +++++++++--------- 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 2d549822..2dd04574 100644 --- a/README.md +++ b/README.md @@ -444,7 +444,7 @@ export GITHUB_MCP_TOOL_ADD_ISSUE_COMMENT_DESCRIPTION="an alternative description - `owner`: Repository owner (string, required) - `repo`: Repository name (string, required) - - `workflowId`: Workflow ID or filename (string, required) + - `workflow_file`: Workflow ID or filename (string, required) - `ref`: Git reference (branch or tag name) (string, required) - `inputs`: Workflow inputs (object, optional) diff --git a/pkg/github/actions.go b/pkg/github/actions.go index f9169d47..f9e7b79e 100644 --- a/pkg/github/actions.go +++ b/pkg/github/actions.go @@ -23,9 +23,9 @@ func RunWorkflow(getClient GetClientFn, t translations.TranslationHelperFunc) (t mcp.Required(), mcp.Description("Repository name"), ), - mcp.WithString("workflowId", + mcp.WithString("workflow_file", mcp.Required(), - mcp.Description("The ID of the workflow. You can also pass the workflow file name as a string."), + mcp.Description("The workflow file name or ID of the workflow entity."), ), mcp.WithString("ref", mcp.Required(), @@ -44,7 +44,7 @@ func RunWorkflow(getClient GetClientFn, t translations.TranslationHelperFunc) (t if err != nil { return mcp.NewToolResultError(err.Error()), nil } - workflowID, err := requiredParam[string](request, "workflowId") + workflowFileName, err := requiredParam[string](request, "workflow_file") if err != nil { return mcp.NewToolResultError(err.Error()), nil } @@ -76,7 +76,7 @@ func RunWorkflow(getClient GetClientFn, t translations.TranslationHelperFunc) (t return nil, fmt.Errorf("failed to get GitHub client: %w", err) } - resp, err := client.Actions.CreateWorkflowDispatchEventByFileName(ctx, owner, repo, workflowID, event) + resp, err := client.Actions.CreateWorkflowDispatchEventByFileName(ctx, owner, repo, workflowFileName, event) if err != nil { return nil, fmt.Errorf("failed to trigger workflow: %w", err) } diff --git a/pkg/github/actions_test.go b/pkg/github/actions_test.go index 08514b6b..b04ca354 100644 --- a/pkg/github/actions_test.go +++ b/pkg/github/actions_test.go @@ -22,10 +22,10 @@ func Test_RunWorkflow(t *testing.T) { 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, "workflowId") + 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", "workflowId", "ref"}) + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "workflow_file", "ref"}) tests := []struct { name string @@ -45,10 +45,10 @@ func Test_RunWorkflow(t *testing.T) { ), ), requestArgs: map[string]any{ - "owner": "owner", - "repo": "repo", - "workflowId": "workflow_id", - "ref": "main", + "owner": "owner", + "repo": "repo", + "workflow_file": "main.yaml", + "ref": "main", "inputs": map[string]any{ "input1": "value1", "input2": "value2", @@ -60,9 +60,9 @@ func Test_RunWorkflow(t *testing.T) { name: "missing required parameter", mockedClient: mock.NewMockedHTTPClient(), requestArgs: map[string]any{ - "owner": "owner", - "repo": "repo", - "workflowId": "main.yaml", + "owner": "owner", + "repo": "repo", + "workflow_file": "main.yaml", // missing ref }, expectError: true,