From d776940a632797a51f3fa1fe41fc111422d4057f Mon Sep 17 00:00:00 2001
From: Josh Gross <joshmgross@github.com>
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 <joshmgross@github.com>
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 <joshmgross@github.com>
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 <joshmgross@github.com>
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,