diff --git a/README.md b/README.md index b9ef26a0a..45e34ee92 100644 --- a/README.md +++ b/README.md @@ -538,6 +538,16 @@ export GITHUB_MCP_TOOL_ADD_ISSUE_COMMENT_DESCRIPTION="an alternative description - `severity`: Alert severity (string, optional) - `tool_name`: The name of the tool used for code scanning (string, optional) +### Actions + +- **run_workflow** - Trigger a workflow run + + - `owner`: Repository owner (string, required) + - `repo`: Repository name (string, required) + - `workflow_file`: Workflow ID or filename (string, required) + - `ref`: Git reference (branch or tag name) (string, required) + - `inputs`: Workflow inputs (object, optional) + ### Secret Scanning - **get_secret_scanning_alert** - Get a secret scanning alert diff --git a/pkg/github/actions.go b/pkg/github/actions.go new file mode 100644 index 000000000..f9e7b79ec --- /dev/null +++ b/pkg/github/actions.go @@ -0,0 +1,97 @@ +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("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 + } +} diff --git a/pkg/github/actions_test.go b/pkg/github/actions_test.go new file mode 100644 index 000000000..b04ca3548 --- /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, "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"]) + }) + } +} diff --git a/pkg/github/tools.go b/pkg/github/tools.go index 1a4a3b4d1..2488ad517 100644 --- a/pkg/github/tools.go +++ b/pkg/github/tools.go @@ -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) tsg.AddToolset(secretProtection) tsg.AddToolset(experiments) // Enable the requested features 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