- Notifications
You must be signed in to change notification settings - Fork941
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 ourterms of service andprivacy statement. We’ll occasionally send you account related emails.
Already on GitHub?Sign in to your account
base:main
Are you sure you want to change the base?
Uh oh!
There was an error while loading.Please reload this page.
Changes fromall commits
d776940
39099ec
1c6285c
e89ccf6
034621b
1b10c0b
File filter
Filter by extension
Conversations
Uh oh!
There was an error while loading.Please reload this page.
Jump to
Uh oh!
There was an error while loading.Please reload this page.
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,97 @@ | ||
package github | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others.Learn more. There are alot of Actions APIs, I suspect the single-file per API area will break down at some point. I tried defining a separate Collaborator
| ||
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"), | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others.Learn more. This matches the description used by most/all of the other 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("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 | ||
} | ||
} |
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, "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"]) | ||
}) | ||
} | ||
} |
Original file line number | Diff line number | Diff line change | ||
---|---|---|---|---|
@@ -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) | ||||
MemberAuthor
|
varDefaultTools= []string{"all"} |