Skip to content

Commit deb66ce

Browse files
committed
feat: add support for running workflows by ID and filename in GitHub Actions tools
- Introduced a new tool, RunWorkflowByFileName, to allow users to run workflows using the workflow filename. - Updated the existing RunWorkflow tool to accept a numeric workflow ID instead of a filename. - Enhanced tests to cover scenarios for both running workflows by ID and filename, including error handling for missing parameters. - Improved tool descriptions for clarity and usability.
1 parent 1339249 commit deb66ce

File tree

3 files changed

+185
-10
lines changed

3 files changed

+185
-10
lines changed

pkg/github/actions.go

Lines changed: 98 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -229,24 +229,24 @@ func ListWorkflowRuns(getClient GetClientFn, t translations.TranslationHelperFun
229229
}
230230
}
231231

232-
// RunWorkflow creates a tool to run an Actions workflow
232+
// RunWorkflow creates a tool to run an Actions workflow by workflow ID
233233
func RunWorkflow(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
234234
return mcp.NewTool("run_workflow",
235-
mcp.WithDescription(t("TOOL_RUN_WORKFLOW_DESCRIPTION", "Run an Actions workflow")),
235+
mcp.WithDescription(t("TOOL_RUN_WORKFLOW_DESCRIPTION", "Run an Actions workflow by workflow ID")),
236236
mcp.WithToolAnnotation(mcp.ToolAnnotation{
237237
ReadOnlyHint: ToBoolPtr(false),
238238
}),
239239
mcp.WithString("owner",
240240
mcp.Required(),
241-
mcp.Description("The account owner of the repository. The name is not case sensitive."),
241+
mcp.Description("Repository owner"),
242242
),
243243
mcp.WithString("repo",
244244
mcp.Required(),
245245
mcp.Description("Repository name"),
246246
),
247-
mcp.WithString("workflow_file",
247+
mcp.WithNumber("workflow_id",
248248
mcp.Required(),
249-
mcp.Description("The workflow ID or workflow file name"),
249+
mcp.Description("The workflow ID (numeric identifier)"),
250250
),
251251
mcp.WithString("ref",
252252
mcp.Required(),
@@ -265,10 +265,11 @@ func RunWorkflow(getClient GetClientFn, t translations.TranslationHelperFunc) (t
265265
if err != nil {
266266
return mcp.NewToolResultError(err.Error()), nil
267267
}
268-
workflowFile, err := RequiredParam[string](request, "workflow_file")
268+
workflowIDInt, err := RequiredInt(request, "workflow_id")
269269
if err != nil {
270270
return mcp.NewToolResultError(err.Error()), nil
271271
}
272+
workflowID := int64(workflowIDInt)
272273
ref, err := RequiredParam[string](request, "ref")
273274
if err != nil {
274275
return mcp.NewToolResultError(err.Error()), nil
@@ -292,15 +293,17 @@ func RunWorkflow(getClient GetClientFn, t translations.TranslationHelperFunc) (t
292293
Inputs: inputs,
293294
}
294295

295-
resp, err := client.Actions.CreateWorkflowDispatchEventByFileName(ctx, owner, repo, workflowFile, event)
296+
// Convert workflow ID to string format for the API call
297+
workflowIDStr := fmt.Sprintf("%d", workflowID)
298+
resp, err := client.Actions.CreateWorkflowDispatchEventByFileName(ctx, owner, repo, workflowIDStr, event)
296299
if err != nil {
297300
return nil, fmt.Errorf("failed to run workflow: %w", err)
298301
}
299302
defer func() { _ = resp.Body.Close() }()
300303

301304
result := map[string]any{
302305
"message": "Workflow run has been queued",
303-
"workflow": workflowFile,
306+
"workflow_id": workflowID,
304307
"ref": ref,
305308
"inputs": inputs,
306309
"status": resp.Status,
@@ -316,6 +319,93 @@ func RunWorkflow(getClient GetClientFn, t translations.TranslationHelperFunc) (t
316319
}
317320
}
318321

322+
// RunWorkflowByFileName creates a tool to run an Actions workflow by filename
323+
func RunWorkflowByFileName(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
324+
return mcp.NewTool("run_workflow_by_filename",
325+
mcp.WithDescription(t("TOOL_RUN_WORKFLOW_BY_FILENAME_DESCRIPTION", "Run an Actions workflow by workflow filename")),
326+
mcp.WithToolAnnotation(mcp.ToolAnnotation{
327+
ReadOnlyHint: ToBoolPtr(false),
328+
}),
329+
mcp.WithString("owner",
330+
mcp.Required(),
331+
mcp.Description("Repository owner"),
332+
),
333+
mcp.WithString("repo",
334+
mcp.Required(),
335+
mcp.Description("Repository name"),
336+
),
337+
mcp.WithString("workflow_file",
338+
mcp.Required(),
339+
mcp.Description("The workflow file name (e.g., main.yml, ci.yaml)"),
340+
),
341+
mcp.WithString("ref",
342+
mcp.Required(),
343+
mcp.Description("The git reference for the workflow. The reference can be a branch or tag name."),
344+
),
345+
mcp.WithObject("inputs",
346+
mcp.Description("Inputs the workflow accepts"),
347+
),
348+
),
349+
func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
350+
owner, err := RequiredParam[string](request, "owner")
351+
if err != nil {
352+
return mcp.NewToolResultError(err.Error()), nil
353+
}
354+
repo, err := RequiredParam[string](request, "repo")
355+
if err != nil {
356+
return mcp.NewToolResultError(err.Error()), nil
357+
}
358+
workflowFile, err := RequiredParam[string](request, "workflow_file")
359+
if err != nil {
360+
return mcp.NewToolResultError(err.Error()), nil
361+
}
362+
ref, err := RequiredParam[string](request, "ref")
363+
if err != nil {
364+
return mcp.NewToolResultError(err.Error()), nil
365+
}
366+
367+
// Get optional inputs parameter
368+
var inputs map[string]interface{}
369+
if requestInputs, ok := request.GetArguments()["inputs"]; ok {
370+
if inputsMap, ok := requestInputs.(map[string]interface{}); ok {
371+
inputs = inputsMap
372+
}
373+
}
374+
375+
client, err := getClient(ctx)
376+
if err != nil {
377+
return nil, fmt.Errorf("failed to get GitHub client: %w", err)
378+
}
379+
380+
event := github.CreateWorkflowDispatchEventRequest{
381+
Ref: ref,
382+
Inputs: inputs,
383+
}
384+
385+
resp, err := client.Actions.CreateWorkflowDispatchEventByFileName(ctx, owner, repo, workflowFile, event)
386+
if err != nil {
387+
return nil, fmt.Errorf("failed to run workflow: %w", err)
388+
}
389+
defer func() { _ = resp.Body.Close() }()
390+
391+
result := map[string]any{
392+
"message": "Workflow run has been queued",
393+
"workflow_file": workflowFile,
394+
"ref": ref,
395+
"inputs": inputs,
396+
"status": resp.Status,
397+
"status_code": resp.StatusCode,
398+
}
399+
400+
r, err := json.Marshal(result)
401+
if err != nil {
402+
return nil, fmt.Errorf("failed to marshal response: %w", err)
403+
}
404+
405+
return mcp.NewToolResultText(string(r)), nil
406+
}
407+
}
408+
319409
// GetWorkflowRun creates a tool to get details of a specific workflow run
320410
func GetWorkflowRun(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
321411
return mcp.NewTool("get_workflow_run",

pkg/github/actions_test.go

Lines changed: 86 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,90 @@ func Test_RunWorkflow(t *testing.T) {
134134
assert.NotEmpty(t, tool.Description)
135135
assert.Contains(t, tool.InputSchema.Properties, "owner")
136136
assert.Contains(t, tool.InputSchema.Properties, "repo")
137+
assert.Contains(t, tool.InputSchema.Properties, "workflow_id")
138+
assert.Contains(t, tool.InputSchema.Properties, "ref")
139+
assert.Contains(t, tool.InputSchema.Properties, "inputs")
140+
assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "workflow_id", "ref"})
141+
142+
tests := []struct {
143+
name string
144+
mockedClient *http.Client
145+
requestArgs map[string]any
146+
expectError bool
147+
expectedErrMsg string
148+
}{
149+
{
150+
name: "successful workflow run",
151+
mockedClient: mock.NewMockedHTTPClient(
152+
mock.WithRequestMatchHandler(
153+
mock.PostReposActionsWorkflowsDispatchesByOwnerByRepoByWorkflowId,
154+
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
155+
w.WriteHeader(http.StatusNoContent)
156+
}),
157+
),
158+
),
159+
requestArgs: map[string]any{
160+
"owner": "owner",
161+
"repo": "repo",
162+
"workflow_id": float64(12345),
163+
"ref": "main",
164+
},
165+
expectError: false,
166+
},
167+
{
168+
name: "missing required parameter workflow_id",
169+
mockedClient: mock.NewMockedHTTPClient(),
170+
requestArgs: map[string]any{
171+
"owner": "owner",
172+
"repo": "repo",
173+
"ref": "main",
174+
},
175+
expectError: true,
176+
expectedErrMsg: "missing required parameter: workflow_id",
177+
},
178+
}
179+
180+
for _, tc := range tests {
181+
t.Run(tc.name, func(t *testing.T) {
182+
// Setup client with mock
183+
client := github.NewClient(tc.mockedClient)
184+
_, handler := RunWorkflow(stubGetClientFn(client), translations.NullTranslationHelper)
185+
186+
// Create call request
187+
request := createMCPRequest(tc.requestArgs)
188+
189+
// Call handler
190+
result, err := handler(context.Background(), request)
191+
192+
require.NoError(t, err)
193+
require.Equal(t, tc.expectError, result.IsError)
194+
195+
// Parse the result and get the text content if no error
196+
textContent := getTextResult(t, result)
197+
198+
if tc.expectedErrMsg != "" {
199+
assert.Equal(t, tc.expectedErrMsg, textContent.Text)
200+
return
201+
}
202+
203+
// Unmarshal and verify the result
204+
var response map[string]any
205+
err = json.Unmarshal([]byte(textContent.Text), &response)
206+
require.NoError(t, err)
207+
assert.Equal(t, "Workflow run has been queued", response["message"])
208+
})
209+
}
210+
}
211+
212+
func Test_RunWorkflowByFileName(t *testing.T) {
213+
// Verify tool definition once
214+
mockClient := github.NewClient(nil)
215+
tool, _ := RunWorkflowByFileName(stubGetClientFn(mockClient), translations.NullTranslationHelper)
216+
217+
assert.Equal(t, "run_workflow_by_filename", tool.Name)
218+
assert.NotEmpty(t, tool.Description)
219+
assert.Contains(t, tool.InputSchema.Properties, "owner")
220+
assert.Contains(t, tool.InputSchema.Properties, "repo")
137221
assert.Contains(t, tool.InputSchema.Properties, "workflow_file")
138222
assert.Contains(t, tool.InputSchema.Properties, "ref")
139223
assert.Contains(t, tool.InputSchema.Properties, "inputs")
@@ -147,7 +231,7 @@ func Test_RunWorkflow(t *testing.T) {
147231
expectedErrMsg string
148232
}{
149233
{
150-
name: "successful workflow run",
234+
name: "successful workflow run by filename",
151235
mockedClient: mock.NewMockedHTTPClient(
152236
mock.WithRequestMatchHandler(
153237
mock.PostReposActionsWorkflowsDispatchesByOwnerByRepoByWorkflowId,
@@ -181,7 +265,7 @@ func Test_RunWorkflow(t *testing.T) {
181265
t.Run(tc.name, func(t *testing.T) {
182266
// Setup client with mock
183267
client := github.NewClient(tc.mockedClient)
184-
_, handler := RunWorkflow(stubGetClientFn(client), translations.NullTranslationHelper)
268+
_, handler := RunWorkflowByFileName(stubGetClientFn(client), translations.NullTranslationHelper)
185269

186270
// Create call request
187271
request := createMCPRequest(tc.requestArgs)

pkg/github/tools.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,7 @@ func DefaultToolsetGroup(readOnly bool, getClient GetClientFn, getGQLClient GetG
125125
).
126126
AddWriteTools(
127127
toolsets.NewServerTool(RunWorkflow(getClient, t)),
128+
toolsets.NewServerTool(RunWorkflowByFileName(getClient, t)),
128129
toolsets.NewServerTool(RerunWorkflowRun(getClient, t)),
129130
toolsets.NewServerTool(RerunFailedJobs(getClient, t)),
130131
toolsets.NewServerTool(CancelWorkflowRun(getClient, t)),

0 commit comments

Comments
 (0)