Skip to content

Add support for running an Actions workflow #269

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 6 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
99 changes: 99 additions & 0 deletions pkg/github/actions.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
package github
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are a lot of Actions APIs, I suspect the single-file per API area will break down at some point.

I tried defining a separate actions package, but it created some circular dependencies with this github package.
I'd be happy to explore that more, but I wanted to keep these changes focused on this tool.

Copy link
Collaborator

@SamMorrowDrums SamMorrowDrums Apr 15, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Appreciate you mentioning though, it's same reason I didn't consider new packages for tools yet.

Pretty sure the helpers and server can be extracted to their own packages to avoid this.

CC @williammartin @juruen

Copy link
Collaborator

@juruen juruen Apr 15, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are a lot of Actions APIs, I suspect the single-file per API area will break down at some point.

I tried defining a separate actions package, but it created some circular dependencies with this github package.

This might open an interesting debate that really resonates with me!

When I started writing Go, I came from other languages where quickly deciding to use packages was the norm. So I followed suit, and my initial reaction was to do the same. However, over time, I've come to terms with the fact that this might not be entirely idiomatic.

Take go-github, for example — they implement almost the entire GitHub API within a single package.

So my point here is that maybe we don't need to introduce more packages right away. We might consider another alternative, such as splitting the functionality into files like actions_foo.go, actions_bar.go, etc., and that might be more idiomatic.

Having said that, I don't have a strong opinion on it, but I'd like us to consider the above before we make the decision.

Thanks! ❤️


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"),
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No mention of case-sensitivity here. Does that imply that it is case-sensitive? Should we be explicit?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This matches the description used by most/all of the other repo MCP inputs in this repo, so I stuck to being consistent with them.

I was thinking it might make sense to define some constant/reusable input definitions for these common inputs like repo & owner for consistency.

),
mcp.WithString("workflowId",
mcp.Required(),
mcp.Description("The ID of the workflow. You can also pass the workflow file name as a string."),
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just the workflow file name itself is acceptable? Or the relative path to the workflow file?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The API supports both

Relative path

gh api -X POST /repos/joshmgross/actions-testing/actions/workflows/hello-world.yaml/dispatches -f "ref=main"

Full path (note the / must be escaped)

gh api -X POST /repos/joshmgross/actions-testing/actions/workflows/.github%2Fworkflows%2Fhello-world.yaml/dispatches -f "ref=main"

I don't think github/go-github will escape the / by default though, so we might need to handle that 🤔

func (s *ActionsService) CreateWorkflowDispatchEventByFileName(ctx context.Context, owner, repo, workflowFileName string, event CreateWorkflowDispatchEventRequest) (*Response, error) {
	u := fmt.Sprintf("repos/%v/%v/actions/workflows/%v/dispatches", owner, repo, workflowFileName)

	return s.createWorkflowDispatchEvent(ctx, u, &event)
}

https://github.com/google/go-github/blob/6f8bceff0b000e6c8f5a3e60922ca57188de264e/github/actions_workflows.go#L170-L179

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd be tempted to flip this around too, and call it workflow_file or something, as it has file access already it can probably work out what workflows it can provide. The ID is only useful where it has received it from a list, and the list endpoint can probably return the file location as part of the list, which is also more useful than the ID, in terms of editing the file.

Does that make sense? The goal here is to make an API that LLMs comprehend and use natively, so definitely have a think on this.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Renamed to workflow_file - 034621b

),
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 {

Check failure on line 64 in pkg/github/actions.go

View workflow job for this annotation

GitHub Actions / lint

S1031: unnecessary nil check around range (gosimple)
for k, v := range inputs {
inputsMap[k] = v
}
}
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was mostly generated by Copilot, not sure if there's a better way to process an object parameter.


// 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
}
}
104 changes: 104 additions & 0 deletions pkg/github/actions_test.go
Original file line number Diff line number Diff line change
@@ -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"])
})
}
}
5 changes: 5 additions & 0 deletions pkg/github/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
Loading