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 6bfc6ab58..d5c039592 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 000000000..fc6c4c861 --- /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 000000000..08514b6b9 --- /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 da916b987..3eaa53af9 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 fc6c4c861..f9169d47a 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 ce10c4ada..84c3c789a 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 2d549822f..2dd045742 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 f9169d47a..f9e7b79ec 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 08514b6b9..b04ca3548 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, pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy