From dc6f457a4a291f11713ff48cc4bbfd8c89c8c62f Mon Sep 17 00:00:00 2001 From: Adam Holt Date: Wed, 15 Oct 2025 14:35:32 +0200 Subject: [PATCH 01/22] Workflow read --- pkg/github/actions.go | 121 ++++++++++++++++++++++++++++ pkg/github/actions_test.go | 156 +++++++++++++++++++++++++++++++++++++ 2 files changed, 277 insertions(+) diff --git a/pkg/github/actions.go b/pkg/github/actions.go index ace9d7288..e9e2bf9fb 100644 --- a/pkg/github/actions.go +++ b/pkg/github/actions.go @@ -22,6 +22,127 @@ const ( DescriptionRepositoryName = "Repository name" ) +type actionsResource int + +const ( + actionsResourceUnknown actionsResource = iota + actionsResourceWorkflow + actionsResourceWorkflowRun + actionsResourceWorkflowJob +) + +func (r actionsResource) String() string { + switch r { + case actionsResourceUnknown: + return "unknown" + case actionsResourceWorkflow: + return "workflow" + case actionsResourceWorkflowRun: + return "workflow_run" + case actionsResourceWorkflowJob: + return "workflow_job" + } + return "unknown" +} + +func ActionsResourceFromString(s string) actionsResource { + switch strings.ToLower(s) { + case "workflow": + return actionsResourceWorkflow + case "workflow_run": + return actionsResourceWorkflowRun + case "workflow_job": + return actionsResourceWorkflowJob + default: + return actionsResourceUnknown + } +} + +func ActionsRead(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("actions_resource_read", + mcp.WithDescription(t("TOOL_ACTIONS_READ_DESCRIPTION", "Tools for reading GitHub Actions resources")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_ACTIONS_READ_USER_TITLE", "Read GitHub Actions"), + ReadOnlyHint: ToBoolPtr(true), + }), + mcp.WithString("resource", + mcp.Required(), + mcp.Description("The type of Actions resource to read"), + mcp.Enum( + actionsResourceWorkflow.String(), + actionsResourceWorkflowRun.String(), + actionsResourceWorkflowJob.String(), + ), + ), + mcp.WithString("owner", + mcp.Required(), + mcp.Description(DescriptionRepositoryOwner), + ), + mcp.WithString("repo", + mcp.Required(), + mcp.Description(DescriptionRepositoryName), + ), + mcp.WithNumber("resource_id", + mcp.Required(), + mcp.Description("The unique identifier of the resource"), + ), + ), + 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 + } + resourceTypeStr, err := RequiredParam[string](request, "resource") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + resourceType := ActionsResourceFromString(resourceTypeStr) + + resourceIDInt, err := RequiredInt(request, "resource_id") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + client, err := getClient(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + + switch resourceType { + case actionsResourceWorkflow: + return getActionsResourceWorkflow(ctx, client, owner, repo, int64(resourceIDInt)) + case actionsResourceWorkflowRun: + return nil, fmt.Errorf("get workflow run by ID not implemented yet") + case actionsResourceWorkflowJob: + return nil, fmt.Errorf("get workflow job by ID not implemented yet") + case actionsResourceUnknown: + return mcp.NewToolResultError(fmt.Sprintf("unknown resource type: %s", resourceTypeStr)), nil + default: + // Should not reach here + return mcp.NewToolResultError("unhandled resource type"), nil + } + } +} + +func getActionsResourceWorkflow(ctx context.Context, client *github.Client, owner, repo string, resourceID int64) (*mcp.CallToolResult, error) { + workflow, resp, err := client.Actions.GetWorkflowByID(ctx, owner, repo, resourceID) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to get workflow", resp, err), nil + } + + defer func() { _ = resp.Body.Close() }() + r, err := json.Marshal(workflow) + if err != nil { + return nil, fmt.Errorf("failed to marshal workflow: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil +} + // ListWorkflows creates a tool to list workflows in a repository func ListWorkflows(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { return mcp.NewTool("list_workflows", diff --git a/pkg/github/actions_test.go b/pkg/github/actions_test.go index 555ec04cb..2eec8cd7a 100644 --- a/pkg/github/actions_test.go +++ b/pkg/github/actions_test.go @@ -1319,3 +1319,159 @@ func Test_MemoryUsage_SlidingWindow_vs_NoWindow(t *testing.T) { t.Logf("Sliding window: %s", profile1.String()) t.Logf("No window: %s", profile2.String()) } + +func Test_ActionsRead_Workflow(t *testing.T) { + // Verify tool definition once + mockClient := github.NewClient(nil) + tool, _ := ActionsRead(stubGetClientFn(mockClient), translations.NullTranslationHelper) + + assert.Equal(t, "actions_resource_read", tool.Name) + assert.NotEmpty(t, tool.Description) + assert.Contains(t, tool.InputSchema.Properties, "resource") + assert.Contains(t, tool.InputSchema.Properties, "owner") + assert.Contains(t, tool.InputSchema.Properties, "repo") + assert.Contains(t, tool.InputSchema.Properties, "resource_id") + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"resource", "owner", "repo", "resource_id"}) + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]any + expectError bool + expectedErrMsg string + }{ + { + name: "missing required parameter resource", + mockedClient: mock.NewMockedHTTPClient(), + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "resource_id": float64(123), + }, + expectError: true, + expectedErrMsg: "missing required parameter: resource", + }, + { + name: "missing required parameter owner", + mockedClient: mock.NewMockedHTTPClient(), + requestArgs: map[string]any{ + "resource": "workflow", + "repo": "repo", + "resource_id": float64(123), + }, + expectError: true, + expectedErrMsg: "missing required parameter: owner", + }, + { + name: "missing required parameter repo", + mockedClient: mock.NewMockedHTTPClient(), + requestArgs: map[string]any{ + "resource": "workflow", + "owner": "owner", + "resource_id": float64(123), + }, + expectError: true, + expectedErrMsg: "missing required parameter: repo", + }, + { + name: "missing required parameter resource_id", + mockedClient: mock.NewMockedHTTPClient(), + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "resource": "workflow", + }, + expectError: true, + expectedErrMsg: "missing required parameter: resource_id", + }, + { + name: "unknown resource", + mockedClient: mock.NewMockedHTTPClient(), + requestArgs: map[string]any{ + "resource": "random", + "owner": "owner", + "repo": "repo", + "resource_id": float64(123), + }, + expectError: true, + expectedErrMsg: "unknown resource type: random", + }, + { + name: "successful workflow read", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposActionsWorkflowsByOwnerByRepoByWorkflowId, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + workflow := &github.Workflow{ + ID: github.Ptr(int64(1)), + NodeID: github.Ptr("W_1"), + Name: github.Ptr("CI"), + Path: github.Ptr(".github/workflows/test.yaml"), + State: github.Ptr("active"), + } + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(workflow) + }), + ), + ), + requestArgs: map[string]any{ + "resource": "workflow", + "owner": "owner", + "repo": "repo", + "resource_id": float64(1), + }, + expectError: false, + }, + { + name: "missing workflow read", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposActionsWorkflowsByOwnerByRepoByWorkflowId, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + }), + ), + ), + requestArgs: map[string]any{ + "resource": "workflow", + "owner": "owner", + "repo": "repo", + "resource_id": float64(2), + }, + expectError: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Setup client with mock + client := github.NewClient(tc.mockedClient) + _, handler := ActionsRead(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 github.Workflow + err = json.Unmarshal([]byte(textContent.Text), &response) + require.NoError(t, err) + assert.NotNil(t, response.ID) + assert.NotNil(t, response.Name) + assert.NotNil(t, response.Path) + }) + } +} From 8e58df75a73928ded74f47991d51a7c5227c11ec Mon Sep 17 00:00:00 2001 From: Adam Holt Date: Wed, 15 Oct 2025 14:44:06 +0200 Subject: [PATCH 02/22] Add additional toolsets in remote mcp --- README.md | 9 ++++++++- docs/remote-server.md | 1 + 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index c0ac851a7..be0dc9c80 100644 --- a/README.md +++ b/README.md @@ -375,7 +375,7 @@ GITHUB_TOOLSETS="all" ./github-mcp-server ### Available Toolsets -The following sets of tools are available (all are on by default): +The following sets of tools are available: | Toolset | Description | @@ -400,6 +400,13 @@ The following sets of tools are available (all are on by default): | `users` | GitHub User related tools | +### Additional Toolsets in Remote Github MCP Server + +| Toolset | Description | +| ----------------------- | ------------------------------------------------------------- | +| `copilot` | Copilot related tools (e.g. Copilot Coding Agent) | +| `copilot_spaces` | Copilot Spaces related tools | + ## Tools diff --git a/docs/remote-server.md b/docs/remote-server.md index 61815a482..f1ac14c33 100644 --- a/docs/remote-server.md +++ b/docs/remote-server.md @@ -22,6 +22,7 @@ Below is a table of available toolsets for the remote GitHub MCP Server. Each to | all | All available GitHub MCP tools | https://api.githubcopilot.com/mcp/ | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=github&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2F%22%7D) | [read-only](https://api.githubcopilot.com/mcp/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=github&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Freadonly%22%7D) | | Actions | GitHub Actions workflows and CI/CD operations | https://api.githubcopilot.com/mcp/x/actions | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-actions&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Factions%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/actions/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-actions&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Factions%2Freadonly%22%7D) | | Code Security | Code security related tools, such as GitHub Code Scanning | https://api.githubcopilot.com/mcp/x/code_security | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-code_security&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcode_security%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/code_security/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-code_security&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcode_security%2Freadonly%22%7D) | +| Copilot Spaces | Copilot Spaces tools | https://api.githubcopilot.com/mcp/x/copilot_spaces | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-copilot_spaces&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcopilot_spaces%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/copilot_spaces/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-code_security&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcode_security%2Fcopilot_spaces%22%7D) | | Dependabot | Dependabot tools | https://api.githubcopilot.com/mcp/x/dependabot | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-dependabot&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fdependabot%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/dependabot/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-dependabot&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fdependabot%2Freadonly%22%7D) | | Discussions | GitHub Discussions related tools | https://api.githubcopilot.com/mcp/x/discussions | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-discussions&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fdiscussions%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/discussions/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-discussions&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fdiscussions%2Freadonly%22%7D) | | Experiments | Experimental features that are not considered stable yet | https://api.githubcopilot.com/mcp/x/experiments | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-experiments&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fexperiments%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/experiments/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-experiments&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fexperiments%2Freadonly%22%7D) | From 29ed3431658cfd71ebd44b80cab99eecb95cf650 Mon Sep 17 00:00:00 2001 From: Adam Holt Date: Wed, 15 Oct 2025 14:47:09 +0200 Subject: [PATCH 03/22] Add copilot --- docs/remote-server.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/remote-server.md b/docs/remote-server.md index f1ac14c33..f17c33011 100644 --- a/docs/remote-server.md +++ b/docs/remote-server.md @@ -22,7 +22,8 @@ Below is a table of available toolsets for the remote GitHub MCP Server. Each to | all | All available GitHub MCP tools | https://api.githubcopilot.com/mcp/ | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=github&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2F%22%7D) | [read-only](https://api.githubcopilot.com/mcp/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=github&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Freadonly%22%7D) | | Actions | GitHub Actions workflows and CI/CD operations | https://api.githubcopilot.com/mcp/x/actions | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-actions&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Factions%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/actions/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-actions&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Factions%2Freadonly%22%7D) | | Code Security | Code security related tools, such as GitHub Code Scanning | https://api.githubcopilot.com/mcp/x/code_security | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-code_security&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcode_security%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/code_security/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-code_security&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcode_security%2Freadonly%22%7D) | -| Copilot Spaces | Copilot Spaces tools | https://api.githubcopilot.com/mcp/x/copilot_spaces | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-copilot_spaces&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcopilot_spaces%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/copilot_spaces/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-code_security&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcode_security%2Fcopilot_spaces%22%7D) | +| Copilot | Copilot related tools | https://api.githubcopilot.com/mcp/x/copilot | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-copilot_spaces&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcopilot_spaces%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/copilot_spaces/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-code_security&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcopilot_spaces%2Freadonly%22%7D) | +| Copilot Spaces | Copilot Spaces tools | https://api.githubcopilot.com/mcp/x/copilot_spaces | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-copilot&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcopilot_spaces%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/copilot_spaces/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-copilot&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcopilot%2Freadonly%22%7D) | | Dependabot | Dependabot tools | https://api.githubcopilot.com/mcp/x/dependabot | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-dependabot&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fdependabot%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/dependabot/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-dependabot&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fdependabot%2Freadonly%22%7D) | | Discussions | GitHub Discussions related tools | https://api.githubcopilot.com/mcp/x/discussions | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-discussions&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fdiscussions%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/discussions/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-discussions&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fdiscussions%2Freadonly%22%7D) | | Experiments | Experimental features that are not considered stable yet | https://api.githubcopilot.com/mcp/x/experiments | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-experiments&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fexperiments%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/experiments/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-experiments&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fexperiments%2Freadonly%22%7D) | From f0b883568adc446d014bde251e703a861fe73ff8 Mon Sep 17 00:00:00 2001 From: Adam Holt Date: Fri, 17 Oct 2025 15:34:26 +0200 Subject: [PATCH 04/22] Add implementations for consolidated Actions resource read tool --- pkg/github/actions.go | 304 ++++++++++++++++++++++++++++++---- pkg/github/actions_test.go | 325 ++++++++++++++++++++++++++++++++++++- pkg/github/tools.go | 13 +- 3 files changed, 601 insertions(+), 41 deletions(-) diff --git a/pkg/github/actions.go b/pkg/github/actions.go index e9e2bf9fb..a5f5b4caa 100644 --- a/pkg/github/actions.go +++ b/pkg/github/actions.go @@ -26,36 +26,40 @@ type actionsResource int const ( actionsResourceUnknown actionsResource = iota - actionsResourceWorkflow - actionsResourceWorkflowRun - actionsResourceWorkflowJob + actionsResourceGetWorkflow + actionsResourceGetWorkflowRun + actionsResourceGetWorkflowRuns + actionsResourceGetWorkflowJob + actionsResourceGetWorkflowJobs + actionsResourceDownloadWorkflowArtifact + actionsResourceGetWorkflowArtifacts ) +var actionsResourceTypes = map[actionsResource]string{ + actionsResourceGetWorkflow: "workflow", + actionsResourceGetWorkflowRun: "workflow_run", + actionsResourceGetWorkflowRuns: "workflow_runs", + actionsResourceGetWorkflowJob: "workflow_job", + actionsResourceGetWorkflowJobs: "workflow_jobs", + actionsResourceDownloadWorkflowArtifact: "workflow_artifact", + actionsResourceGetWorkflowArtifacts: "workflow_artifacts", +} + func (r actionsResource) String() string { - switch r { - case actionsResourceUnknown: - return "unknown" - case actionsResourceWorkflow: - return "workflow" - case actionsResourceWorkflowRun: - return "workflow_run" - case actionsResourceWorkflowJob: - return "workflow_job" + if str, ok := actionsResourceTypes[r]; ok { + return str } + return "unknown" } func ActionsResourceFromString(s string) actionsResource { - switch strings.ToLower(s) { - case "workflow": - return actionsResourceWorkflow - case "workflow_run": - return actionsResourceWorkflowRun - case "workflow_job": - return actionsResourceWorkflowJob - default: - return actionsResourceUnknown + for r, str := range actionsResourceTypes { + if str == strings.ToLower(s) { + return r + } } + return actionsResourceUnknown } func ActionsRead(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { @@ -69,9 +73,13 @@ func ActionsRead(getClient GetClientFn, t translations.TranslationHelperFunc) (t mcp.Required(), mcp.Description("The type of Actions resource to read"), mcp.Enum( - actionsResourceWorkflow.String(), - actionsResourceWorkflowRun.String(), - actionsResourceWorkflowJob.String(), + actionsResourceGetWorkflow.String(), + actionsResourceGetWorkflowRun.String(), + actionsResourceGetWorkflowRuns.String(), + actionsResourceGetWorkflowJob.String(), + actionsResourceGetWorkflowJobs.String(), + actionsResourceDownloadWorkflowArtifact.String(), + actionsResourceGetWorkflowArtifacts.String(), ), ), mcp.WithString("owner", @@ -84,8 +92,79 @@ func ActionsRead(getClient GetClientFn, t translations.TranslationHelperFunc) (t ), mcp.WithNumber("resource_id", mcp.Required(), - mcp.Description("The unique identifier of the resource"), + mcp.Description(`The unique identifier of the resource. This will vary based on the "resource" provided, so ensure you provide the correct ID: +- Provide a workflow ID for 'workflow' and 'workflow_runs' resources. +- Provide a workflow run ID for 'workflow_run', 'workflow_jobs', 'workflow_artifact' and 'workflow_artifacts'. +- Provide a job ID for 'workflow_job' resource. +`), + ), + mcp.WithObject("workflow_runs_filter", + mcp.Description("Filters for workflow runs. **ONLY** used when resource is 'workflow_runs'"), + mcp.Properties(map[string]any{ + "actor": map[string]any{ + "type": "string", + "description": "Returns someone's workflow runs. Use the login for the user who created the workflow run.", + }, + "branch": map[string]any{ + "type": "string", + "description": "Returns workflow runs associated with a branch. Use the name of the branch.", + }, + "event": map[string]any{ + "type": "string", + "description": "Returns workflow runs for a specific event type", + "enum": []string{ + "branch_protection_rule", + "check_run", + "check_suite", + "create", + "delete", + "deployment", + "deployment_status", + "discussion", + "discussion_comment", + "fork", + "gollum", + "issue_comment", + "issues", + "label", + "merge_group", + "milestone", + "page_build", + "public", + "pull_request", + "pull_request_review", + "pull_request_review_comment", + "pull_request_target", + "push", + "registry_package", + "release", + "repository_dispatch", + "schedule", + "status", + "watch", + "workflow_call", + "workflow_dispatch", + "workflow_run", + }, + }, + "status": map[string]any{ + "type": "string", + "description": "Returns workflow runs with the check run status", + "enum": []string{"queued", "in_progress", "completed", "requested", "waiting"}, + }, + }), + ), + mcp.WithObject("workflow_jobs_filter", + mcp.Description("Filters for workflow jobs. **ONLY** used when resource is 'workflow_jobs'"), + mcp.Properties(map[string]any{ + "filter": map[string]any{ + "type": "string", + "description": "Filters jobs by their completed_at timestamp", + "enum": []string{"latest", "all"}, + }, + }), ), + WithPagination(), ), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { owner, err := RequiredParam[string](request, "owner") @@ -100,25 +179,42 @@ func ActionsRead(getClient GetClientFn, t translations.TranslationHelperFunc) (t if err != nil { return mcp.NewToolResultError(err.Error()), nil } + resourceType := ActionsResourceFromString(resourceTypeStr) + if resourceType == actionsResourceUnknown { + return mcp.NewToolResultError(fmt.Sprintf("unknown resource type: %s", resourceTypeStr)), nil + } resourceIDInt, err := RequiredInt(request, "resource_id") if err != nil { return mcp.NewToolResultError(err.Error()), nil } + pagination, err := OptionalPaginationParams(request) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + client, err := getClient(ctx) if err != nil { return nil, fmt.Errorf("failed to get GitHub client: %w", err) } switch resourceType { - case actionsResourceWorkflow: - return getActionsResourceWorkflow(ctx, client, owner, repo, int64(resourceIDInt)) - case actionsResourceWorkflowRun: - return nil, fmt.Errorf("get workflow run by ID not implemented yet") - case actionsResourceWorkflowJob: - return nil, fmt.Errorf("get workflow job by ID not implemented yet") + case actionsResourceGetWorkflow: + return getActionsResourceWorkflow(ctx, client, request, owner, repo, int64(resourceIDInt)) + case actionsResourceGetWorkflowRun: + return getActionsResourceWorkflowRun(ctx, client, request, owner, repo, int64(resourceIDInt)) + case actionsResourceGetWorkflowRuns: + return getActionsResourceWorkflowRuns(ctx, client, request, owner, repo, int64(resourceIDInt), pagination) + case actionsResourceGetWorkflowJob: + return getActionsResourceWorkflowJob(ctx, client, request, owner, repo, int64(resourceIDInt)) + case actionsResourceGetWorkflowJobs: + return getActionsResourceWorkflowJobs(ctx, client, request, owner, repo, int64(resourceIDInt), pagination) + case actionsResourceDownloadWorkflowArtifact: + return getActionsResourceDownloadWorkflowArtifact(ctx, client, request, owner, repo, int64(resourceIDInt)) + case actionsResourceGetWorkflowArtifacts: + return getActionsResourceWorkflowArtifacts(ctx, client, request, owner, repo, int64(resourceIDInt), pagination) case actionsResourceUnknown: return mcp.NewToolResultError(fmt.Sprintf("unknown resource type: %s", resourceTypeStr)), nil default: @@ -128,7 +224,7 @@ func ActionsRead(getClient GetClientFn, t translations.TranslationHelperFunc) (t } } -func getActionsResourceWorkflow(ctx context.Context, client *github.Client, owner, repo string, resourceID int64) (*mcp.CallToolResult, error) { +func getActionsResourceWorkflow(ctx context.Context, client *github.Client, _ mcp.CallToolRequest, owner, repo string, resourceID int64) (*mcp.CallToolResult, error) { workflow, resp, err := client.Actions.GetWorkflowByID(ctx, owner, repo, resourceID) if err != nil { return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to get workflow", resp, err), nil @@ -143,6 +239,150 @@ func getActionsResourceWorkflow(ctx context.Context, client *github.Client, owne return mcp.NewToolResultText(string(r)), nil } +func getActionsResourceWorkflowRun(ctx context.Context, client *github.Client, _ mcp.CallToolRequest, owner, repo string, resourceID int64) (*mcp.CallToolResult, error) { + workflowRun, resp, err := client.Actions.GetWorkflowRunByID(ctx, owner, repo, resourceID) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to get workflow run", resp, err), nil + } + defer func() { _ = resp.Body.Close() }() + r, err := json.Marshal(workflowRun) + if err != nil { + return nil, fmt.Errorf("failed to marshal workflow run: %w", err) + } + return mcp.NewToolResultText(string(r)), nil +} + +func getActionsResourceWorkflowRuns(ctx context.Context, client *github.Client, request mcp.CallToolRequest, owner, repo string, resourceID int64, pagination PaginationParams) (*mcp.CallToolResult, error) { + filterArgs, err := OptionalParam[map[string]any](request, "workflow_runs_filter") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + filterArgsTyped := make(map[string]string) + for k, v := range filterArgs { + if strVal, ok := v.(string); ok { + filterArgsTyped[k] = strVal + } else { + filterArgsTyped[k] = "" + } + } + + workflowRuns, resp, err := client.Actions.ListWorkflowRunsByID(ctx, owner, repo, resourceID, &github.ListWorkflowRunsOptions{ + Actor: filterArgsTyped["actor"], + Branch: filterArgsTyped["branch"], + Event: filterArgsTyped["event"], + Status: filterArgsTyped["status"], + ListOptions: github.ListOptions{ + Page: pagination.Page, + PerPage: pagination.PerPage, + }, + }) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to list workflow runs", resp, err), nil + } + + defer func() { _ = resp.Body.Close() }() + r, err := json.Marshal(workflowRuns) + if err != nil { + return nil, fmt.Errorf("failed to marshal workflow runs: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil +} + +func getActionsResourceWorkflowJob(ctx context.Context, client *github.Client, _ mcp.CallToolRequest, owner, repo string, resourceID int64) (*mcp.CallToolResult, error) { + workflowJob, resp, err := client.Actions.GetWorkflowJobByID(ctx, owner, repo, resourceID) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to get workflow job", resp, err), nil + } + defer func() { _ = resp.Body.Close() }() + r, err := json.Marshal(workflowJob) + if err != nil { + return nil, fmt.Errorf("failed to marshal workflow job: %w", err) + } + return mcp.NewToolResultText(string(r)), nil +} + +func getActionsResourceWorkflowJobs(ctx context.Context, client *github.Client, request mcp.CallToolRequest, owner, repo string, resourceID int64, pagination PaginationParams) (*mcp.CallToolResult, error) { + filterArgs, err := OptionalParam[map[string]any](request, "workflow_jobs_filter") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + filterArgsTyped := make(map[string]string) + for k, v := range filterArgs { + if strVal, ok := v.(string); ok { + filterArgsTyped[k] = strVal + } else { + filterArgsTyped[k] = "" + } + } + + workflowJobs, resp, err := client.Actions.ListWorkflowJobs(ctx, owner, repo, resourceID, &github.ListWorkflowJobsOptions{ + Filter: filterArgsTyped["filter"], + ListOptions: github.ListOptions{ + Page: pagination.Page, + PerPage: pagination.PerPage, + }, + }) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to list workflow jobs", resp, err), nil + } + + defer func() { _ = resp.Body.Close() }() + r, err := json.Marshal(workflowJobs) + if err != nil { + return nil, fmt.Errorf("failed to marshal workflow jobs: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil +} + +func getActionsResourceDownloadWorkflowArtifact(ctx context.Context, client *github.Client, _ mcp.CallToolRequest, owner, repo string, resourceID int64) (*mcp.CallToolResult, error) { + // Get the download URL for the artifact + url, resp, err := client.Actions.DownloadArtifact(ctx, owner, repo, resourceID, 1) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to get artifact download URL", resp, err), nil + } + defer func() { _ = resp.Body.Close() }() + + // Create response with the download URL and information + result := map[string]any{ + "download_url": url.String(), + "message": "Artifact is available for download", + "note": "The download_url provides a download link for the artifact as a ZIP archive. The link is temporary and expires after a short time.", + "artifact_id": resourceID, + } + + r, err := json.Marshal(result) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil +} + +func getActionsResourceWorkflowArtifacts(ctx context.Context, client *github.Client, _ mcp.CallToolRequest, owner, repo string, resourceID int64, pagination PaginationParams) (*mcp.CallToolResult, error) { + // Set up list options + opts := &github.ListOptions{ + PerPage: pagination.PerPage, + Page: pagination.Page, + } + + artifacts, resp, err := client.Actions.ListWorkflowRunArtifacts(ctx, owner, repo, resourceID, opts) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to list workflow run artifacts", resp, err), nil + } + defer func() { _ = resp.Body.Close() }() + + r, err := json.Marshal(artifacts) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil +} + // ListWorkflows creates a tool to list workflows in a repository func ListWorkflows(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { return mcp.NewTool("list_workflows", diff --git a/pkg/github/actions_test.go b/pkg/github/actions_test.go index 2eec8cd7a..21fbd23bf 100644 --- a/pkg/github/actions_test.go +++ b/pkg/github/actions_test.go @@ -7,6 +7,7 @@ import ( "net/http" "net/http/httptest" "os" + "regexp" "runtime" "runtime/debug" "strings" @@ -1320,7 +1321,7 @@ func Test_MemoryUsage_SlidingWindow_vs_NoWindow(t *testing.T) { t.Logf("No window: %s", profile2.String()) } -func Test_ActionsRead_Workflow(t *testing.T) { +func Test_ActionsResourceRead(t *testing.T) { // Verify tool definition once mockClient := github.NewClient(nil) tool, _ := ActionsRead(stubGetClientFn(mockClient), translations.NullTranslationHelper) @@ -1396,6 +1397,55 @@ func Test_ActionsRead_Workflow(t *testing.T) { expectError: true, expectedErrMsg: "unknown resource type: random", }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Setup client with mock + client := github.NewClient(tc.mockedClient) + _, handler := ActionsRead(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 + } + }) + } +} + +func Test_ActionsResourceRead_Workflow(t *testing.T) { + // Verify tool definition once + mockClient := github.NewClient(nil) + tool, _ := ActionsRead(stubGetClientFn(mockClient), translations.NullTranslationHelper) + + assert.Equal(t, "actions_resource_read", tool.Name) + assert.NotEmpty(t, tool.Description) + assert.Contains(t, tool.InputSchema.Properties, "resource") + assert.Contains(t, tool.InputSchema.Properties, "owner") + assert.Contains(t, tool.InputSchema.Properties, "repo") + assert.Contains(t, tool.InputSchema.Properties, "resource_id") + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"resource", "owner", "repo", "resource_id"}) + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]any + expectError bool + expectedErrMsg string + expectedErrMsgRegexp *regexp.Regexp + }{ { name: "successful workflow read", mockedClient: mock.NewMockedHTTPClient( @@ -1438,7 +1488,8 @@ func Test_ActionsRead_Workflow(t *testing.T) { "repo": "repo", "resource_id": float64(2), }, - expectError: false, + expectError: true, + expectedErrMsgRegexp: regexp.MustCompile(`^failed to get workflow: GET .*/repos/owner/repo/actions/workflows/2: 404.*$`), }, } @@ -1461,7 +1512,12 @@ func Test_ActionsRead_Workflow(t *testing.T) { textContent := getTextResult(t, result) if tc.expectedErrMsg != "" { - assert.Equal(t, tc.expectedErrMsg, textContent.Text) + assert.Contains(t, tc.expectedErrMsg, textContent.Text) + return + } + + if tc.expectedErrMsgRegexp != nil { + assert.Regexp(t, tc.expectedErrMsgRegexp, textContent.Text) return } @@ -1475,3 +1531,266 @@ func Test_ActionsRead_Workflow(t *testing.T) { }) } } + +func Test_ActionsResourceRead_WorkflowRun(t *testing.T) { + // Verify tool definition once + mockClient := github.NewClient(nil) + tool, _ := ActionsRead(stubGetClientFn(mockClient), translations.NullTranslationHelper) + + assert.Equal(t, "actions_resource_read", tool.Name) + assert.NotEmpty(t, tool.Description) + assert.Contains(t, tool.InputSchema.Properties, "resource") + assert.Contains(t, tool.InputSchema.Properties, "owner") + assert.Contains(t, tool.InputSchema.Properties, "repo") + assert.Contains(t, tool.InputSchema.Properties, "resource_id") + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"resource", "owner", "repo", "resource_id"}) + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]any + expectError bool + expectedErrMsg string + expectedErrMsgRegexp *regexp.Regexp + }{ + { + name: "successful workflow run read", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposActionsRunsByOwnerByRepoByRunId, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + run := &github.WorkflowRun{ + ID: github.Ptr(int64(12345)), + RunNumber: github.Ptr(int(1)), + Status: github.Ptr("completed"), + Conclusion: github.Ptr("success"), + WorkflowID: github.Ptr(int64(1)), + } + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(run) + }), + ), + ), + requestArgs: map[string]any{ + "resource": "workflow_run", + "owner": "owner", + "repo": "repo", + "resource_id": float64(12345), + }, + expectError: false, + }, + { + name: "missing workflow run read", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposActionsRunsByOwnerByRepoByRunId, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + }), + ), + ), + requestArgs: map[string]any{ + "resource": "workflow_run", + "owner": "owner", + "repo": "repo", + "resource_id": float64(99999), + }, + expectError: true, + expectedErrMsgRegexp: regexp.MustCompile(`^failed to get workflow run: GET .*/repos/owner/repo/actions/runs/99999: 404.*$`), + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Setup client with mock + client := github.NewClient(tc.mockedClient) + _, handler := ActionsRead(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.Contains(t, tc.expectedErrMsg, textContent.Text) + return + } + + if tc.expectedErrMsgRegexp != nil { + assert.Regexp(t, tc.expectedErrMsgRegexp, 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, float64(12345), response["id"]) + assert.Equal(t, float64(1), response["run_number"]) + assert.Equal(t, "completed", response["status"]) + assert.Equal(t, "success", response["conclusion"]) + assert.Equal(t, float64(1), response["workflow_id"]) + }) + } +} + +func Test_ActionsResourceRead_WorkflowRuns(t *testing.T) { + // Verify tool definition once + mockClient := github.NewClient(nil) + tool, _ := ActionsRead(stubGetClientFn(mockClient), translations.NullTranslationHelper) + + assert.Equal(t, "actions_resource_read", tool.Name) + assert.NotEmpty(t, tool.Description) + assert.Contains(t, tool.InputSchema.Properties, "resource") + assert.Contains(t, tool.InputSchema.Properties, "owner") + assert.Contains(t, tool.InputSchema.Properties, "repo") + assert.Contains(t, tool.InputSchema.Properties, "resource_id") + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"resource", "owner", "repo", "resource_id"}) + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]any + expectError bool + expectedErrMsg string + expectedErrMsgRegexp *regexp.Regexp + }{ + { + name: "successful workflow runs read", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposActionsWorkflowsRunsByOwnerByRepoByWorkflowId, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + runs := &github.WorkflowRuns{ + TotalCount: github.Ptr(2), + WorkflowRuns: []*github.WorkflowRun{ + { + ID: github.Ptr(int64(12345)), + RunNumber: github.Ptr(int(1)), + Status: github.Ptr("completed"), + Conclusion: github.Ptr("success"), + WorkflowID: github.Ptr(int64(1)), + }, + { + ID: github.Ptr(int64(12346)), + RunNumber: github.Ptr(int(2)), + Status: github.Ptr("completed"), + Conclusion: github.Ptr("failure"), + WorkflowID: github.Ptr(int64(1)), + }, + }, + } + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(runs) + }), + ), + ), + requestArgs: map[string]any{ + "resource": "workflow_runs", + "owner": "owner", + "repo": "repo", + "resource_id": float64(1), + }, + expectError: false, + }, + { + name: "successful workflow runs read with filters", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposActionsWorkflowsRunsByOwnerByRepoByWorkflowId, + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Verify query parameters + query := r.URL.Query() + assert.Equal(t, "omgitsads", query.Get("actor")) + assert.Equal(t, "completed", query.Get("status")) + + runs := &github.WorkflowRuns{ + TotalCount: github.Ptr(1), + WorkflowRuns: []*github.WorkflowRun{ + { + ID: github.Ptr(int64(12345)), + RunNumber: github.Ptr(int(1)), + Status: github.Ptr("completed"), + Conclusion: github.Ptr("success"), + WorkflowID: github.Ptr(int64(1)), + Actor: &github.User{ + Login: github.Ptr("omgitsads"), + }, + }, + }, + } + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(runs) + }), + ), + ), + requestArgs: map[string]any{ + "resource": "workflow_runs", + "owner": "owner", + "repo": "repo", + "resource_id": float64(1), + "workflow_runs_filter": map[string]string{ + "actor": "omgitsads", + "status": "completed", + }, + }, + expectError: false, + }, + { + name: "missing workflow runs read", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposActionsWorkflowsRunsByOwnerByRepoByWorkflowId, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + }), + ), + ), + requestArgs: map[string]any{ + "resource": "workflow_runs", + "owner": "owner", + "repo": "repo", + "resource_id": float64(99999), + }, + expectError: true, + expectedErrMsgRegexp: regexp.MustCompile(`^failed to list workflow runs: GET .*/repos/owner/repo/actions/workflows/99999/runs.* 404.*$`), + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Setup client with mock + client := github.NewClient(tc.mockedClient) + _, handler := ActionsRead(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.Contains(t, tc.expectedErrMsg, textContent.Text) + return + } + + if tc.expectedErrMsgRegexp != nil { + assert.Regexp(t, tc.expectedErrMsgRegexp, textContent.Text) + return + } + }) + } +} diff --git a/pkg/github/tools.go b/pkg/github/tools.go index a982060de..f994a1e11 100644 --- a/pkg/github/tools.go +++ b/pkg/github/tools.go @@ -255,14 +255,15 @@ func DefaultToolsetGroup(readOnly bool, getClient GetClientFn, getGQLClient GetG actions := toolsets.NewToolset(ToolsetMetadataActions.ID, ToolsetMetadataActions.Description). AddReadTools( + toolsets.NewServerTool(ActionsRead(getClient, t)), toolsets.NewServerTool(ListWorkflows(getClient, t)), - toolsets.NewServerTool(ListWorkflowRuns(getClient, t)), - toolsets.NewServerTool(GetWorkflowRun(getClient, t)), - toolsets.NewServerTool(GetWorkflowRunLogs(getClient, t)), - toolsets.NewServerTool(ListWorkflowJobs(getClient, t)), + // toolsets.NewServerTool(ListWorkflowRuns(getClient, t)), + // toolsets.NewServerTool(GetWorkflowRun(getClient, t)), + // toolsets.NewServerTool(GetWorkflowRunLogs(getClient, t)), + // toolsets.NewServerTool(ListWorkflowJobs(getClient, t)), toolsets.NewServerTool(GetJobLogs(getClient, t, contentWindowSize)), - toolsets.NewServerTool(ListWorkflowRunArtifacts(getClient, t)), - toolsets.NewServerTool(DownloadWorkflowRunArtifact(getClient, t)), + // toolsets.NewServerTool(ListWorkflowRunArtifacts(getClient, t)), + // toolsets.NewServerTool(DownloadWorkflowRunArtifact(getClient, t)), toolsets.NewServerTool(GetWorkflowRunUsage(getClient, t)), ). AddWriteTools( From 3cf224cb58610477d094572a8a861bf24b63caed Mon Sep 17 00:00:00 2001 From: Adam Holt Date: Fri, 17 Oct 2025 17:27:54 +0200 Subject: [PATCH 05/22] Renamed resource to action, added usage --- pkg/github/actions.go | 618 ++++++------------------------------------ pkg/github/tools.go | 4 +- 2 files changed, 88 insertions(+), 534 deletions(-) diff --git a/pkg/github/actions.go b/pkg/github/actions.go index a5f5b4caa..d276d5a52 100644 --- a/pkg/github/actions.go +++ b/pkg/github/actions.go @@ -22,30 +22,32 @@ const ( DescriptionRepositoryName = "Repository name" ) -type actionsResource int +type actionsActionType int const ( - actionsResourceUnknown actionsResource = iota - actionsResourceGetWorkflow - actionsResourceGetWorkflowRun - actionsResourceGetWorkflowRuns - actionsResourceGetWorkflowJob - actionsResourceGetWorkflowJobs - actionsResourceDownloadWorkflowArtifact - actionsResourceGetWorkflowArtifacts + actionsActionTypeUnknown actionsActionType = iota + actionsActionTypeGetWorkflow + actionsActionTypeGetWorkflowRun + actionsActionTypeListWorkflowRuns + actionsActionTypeGetWorkflowJob + actionsActionTypeListWorkflowJobs + actionsActionTypeDownloadWorkflowArtifact + actionsActionTypeListWorkflowArtifacts + actionsActionTypeGetWorkflowRunUsage ) -var actionsResourceTypes = map[actionsResource]string{ - actionsResourceGetWorkflow: "workflow", - actionsResourceGetWorkflowRun: "workflow_run", - actionsResourceGetWorkflowRuns: "workflow_runs", - actionsResourceGetWorkflowJob: "workflow_job", - actionsResourceGetWorkflowJobs: "workflow_jobs", - actionsResourceDownloadWorkflowArtifact: "workflow_artifact", - actionsResourceGetWorkflowArtifacts: "workflow_artifacts", +var actionsResourceTypes = map[actionsActionType]string{ + actionsActionTypeGetWorkflow: "get_workflow", + actionsActionTypeGetWorkflowRun: "get_workflow_run", + actionsActionTypeListWorkflowRuns: "list_workflow_runs", + actionsActionTypeGetWorkflowJob: "get_workflow_job", + actionsActionTypeListWorkflowJobs: "list_workflow_jobs", + actionsActionTypeDownloadWorkflowArtifact: "download_workflow_artifact", + actionsActionTypeListWorkflowArtifacts: "list_workflow_artifacts", + actionsActionTypeGetWorkflowRunUsage: "get_workflow_run_usage", } -func (r actionsResource) String() string { +func (r actionsActionType) String() string { if str, ok := actionsResourceTypes[r]; ok { return str } @@ -53,13 +55,13 @@ func (r actionsResource) String() string { return "unknown" } -func ActionsResourceFromString(s string) actionsResource { +func ActionFromString(s string) actionsActionType { for r, str := range actionsResourceTypes { if str == strings.ToLower(s) { return r } } - return actionsResourceUnknown + return actionsActionTypeUnknown } func ActionsRead(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { @@ -69,17 +71,18 @@ func ActionsRead(getClient GetClientFn, t translations.TranslationHelperFunc) (t Title: t("TOOL_ACTIONS_READ_USER_TITLE", "Read GitHub Actions"), ReadOnlyHint: ToBoolPtr(true), }), - mcp.WithString("resource", + mcp.WithString("action", mcp.Required(), - mcp.Description("The type of Actions resource to read"), + mcp.Description("The action to perform"), mcp.Enum( - actionsResourceGetWorkflow.String(), - actionsResourceGetWorkflowRun.String(), - actionsResourceGetWorkflowRuns.String(), - actionsResourceGetWorkflowJob.String(), - actionsResourceGetWorkflowJobs.String(), - actionsResourceDownloadWorkflowArtifact.String(), - actionsResourceGetWorkflowArtifacts.String(), + actionsActionTypeGetWorkflow.String(), + actionsActionTypeGetWorkflowRun.String(), + actionsActionTypeListWorkflowRuns.String(), + actionsActionTypeGetWorkflowJob.String(), + actionsActionTypeListWorkflowJobs.String(), + actionsActionTypeDownloadWorkflowArtifact.String(), + actionsActionTypeListWorkflowArtifacts.String(), + actionsActionTypeGetWorkflowRunUsage.String(), ), ), mcp.WithString("owner", @@ -92,14 +95,14 @@ func ActionsRead(getClient GetClientFn, t translations.TranslationHelperFunc) (t ), mcp.WithNumber("resource_id", mcp.Required(), - mcp.Description(`The unique identifier of the resource. This will vary based on the "resource" provided, so ensure you provide the correct ID: -- Provide a workflow ID for 'workflow' and 'workflow_runs' resources. -- Provide a workflow run ID for 'workflow_run', 'workflow_jobs', 'workflow_artifact' and 'workflow_artifacts'. -- Provide a job ID for 'workflow_job' resource. + mcp.Description(`The unique identifier of the resource. This will vary based on the "action" provided, so ensure you provide the correct ID: +- Provide a workflow ID for 'get_workflow' and 'list_workflow_runs' actions. +- Provide a workflow run ID for 'get_workflow_run', 'list_workflow_jobs', 'download_workflow_artifact', 'list_workflow_artifacts' and 'get_workflow_run_usage' actions. +- Provide a job ID for the 'get_workflow_job' action. `), ), mcp.WithObject("workflow_runs_filter", - mcp.Description("Filters for workflow runs. **ONLY** used when resource is 'workflow_runs'"), + mcp.Description("Filters for workflow runs. **ONLY** used when action is 'list_workflow_runs'"), mcp.Properties(map[string]any{ "actor": map[string]any{ "type": "string", @@ -155,7 +158,7 @@ func ActionsRead(getClient GetClientFn, t translations.TranslationHelperFunc) (t }), ), mcp.WithObject("workflow_jobs_filter", - mcp.Description("Filters for workflow jobs. **ONLY** used when resource is 'workflow_jobs'"), + mcp.Description("Filters for workflow jobs. **ONLY** used when action is 'list_workflow_jobs'"), mcp.Properties(map[string]any{ "filter": map[string]any{ "type": "string", @@ -175,14 +178,14 @@ func ActionsRead(getClient GetClientFn, t translations.TranslationHelperFunc) (t if err != nil { return mcp.NewToolResultError(err.Error()), nil } - resourceTypeStr, err := RequiredParam[string](request, "resource") + actionTypeStr, err := RequiredParam[string](request, "action") if err != nil { return mcp.NewToolResultError(err.Error()), nil } - resourceType := ActionsResourceFromString(resourceTypeStr) - if resourceType == actionsResourceUnknown { - return mcp.NewToolResultError(fmt.Sprintf("unknown resource type: %s", resourceTypeStr)), nil + resourceType := ActionFromString(actionTypeStr) + if resourceType == actionsActionTypeUnknown { + return mcp.NewToolResultError(fmt.Sprintf("unknown action: %s", actionTypeStr)), nil } resourceIDInt, err := RequiredInt(request, "resource_id") @@ -201,30 +204,32 @@ func ActionsRead(getClient GetClientFn, t translations.TranslationHelperFunc) (t } switch resourceType { - case actionsResourceGetWorkflow: - return getActionsResourceWorkflow(ctx, client, request, owner, repo, int64(resourceIDInt)) - case actionsResourceGetWorkflowRun: - return getActionsResourceWorkflowRun(ctx, client, request, owner, repo, int64(resourceIDInt)) - case actionsResourceGetWorkflowRuns: - return getActionsResourceWorkflowRuns(ctx, client, request, owner, repo, int64(resourceIDInt), pagination) - case actionsResourceGetWorkflowJob: - return getActionsResourceWorkflowJob(ctx, client, request, owner, repo, int64(resourceIDInt)) - case actionsResourceGetWorkflowJobs: - return getActionsResourceWorkflowJobs(ctx, client, request, owner, repo, int64(resourceIDInt), pagination) - case actionsResourceDownloadWorkflowArtifact: - return getActionsResourceDownloadWorkflowArtifact(ctx, client, request, owner, repo, int64(resourceIDInt)) - case actionsResourceGetWorkflowArtifacts: - return getActionsResourceWorkflowArtifacts(ctx, client, request, owner, repo, int64(resourceIDInt), pagination) - case actionsResourceUnknown: - return mcp.NewToolResultError(fmt.Sprintf("unknown resource type: %s", resourceTypeStr)), nil + case actionsActionTypeGetWorkflow: + return getWorkflow(ctx, client, request, owner, repo, int64(resourceIDInt)) + case actionsActionTypeGetWorkflowRun: + return getWorkflowRun(ctx, client, request, owner, repo, int64(resourceIDInt)) + case actionsActionTypeListWorkflowRuns: + return listWorkflowRuns(ctx, client, request, owner, repo, int64(resourceIDInt), pagination) + case actionsActionTypeGetWorkflowJob: + return getWorkflowJob(ctx, client, request, owner, repo, int64(resourceIDInt)) + case actionsActionTypeListWorkflowJobs: + return listWorkflowJobs(ctx, client, request, owner, repo, int64(resourceIDInt), pagination) + case actionsActionTypeDownloadWorkflowArtifact: + return downloadWorkflowArtifact(ctx, client, request, owner, repo, int64(resourceIDInt)) + case actionsActionTypeListWorkflowArtifacts: + return listWorkflowArtifacts(ctx, client, request, owner, repo, int64(resourceIDInt), pagination) + case actionsActionTypeGetWorkflowRunUsage: + return getWorkflowRunUsage(ctx, client, request, owner, repo, int64(resourceIDInt)) + case actionsActionTypeUnknown: + return mcp.NewToolResultError(fmt.Sprintf("unknown action: %s", actionTypeStr)), nil default: // Should not reach here - return mcp.NewToolResultError("unhandled resource type"), nil + return mcp.NewToolResultError("unhandled action type"), nil } } } -func getActionsResourceWorkflow(ctx context.Context, client *github.Client, _ mcp.CallToolRequest, owner, repo string, resourceID int64) (*mcp.CallToolResult, error) { +func getWorkflow(ctx context.Context, client *github.Client, _ mcp.CallToolRequest, owner, repo string, resourceID int64) (*mcp.CallToolResult, error) { workflow, resp, err := client.Actions.GetWorkflowByID(ctx, owner, repo, resourceID) if err != nil { return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to get workflow", resp, err), nil @@ -239,7 +244,7 @@ func getActionsResourceWorkflow(ctx context.Context, client *github.Client, _ mc return mcp.NewToolResultText(string(r)), nil } -func getActionsResourceWorkflowRun(ctx context.Context, client *github.Client, _ mcp.CallToolRequest, owner, repo string, resourceID int64) (*mcp.CallToolResult, error) { +func getWorkflowRun(ctx context.Context, client *github.Client, _ mcp.CallToolRequest, owner, repo string, resourceID int64) (*mcp.CallToolResult, error) { workflowRun, resp, err := client.Actions.GetWorkflowRunByID(ctx, owner, repo, resourceID) if err != nil { return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to get workflow run", resp, err), nil @@ -252,7 +257,7 @@ func getActionsResourceWorkflowRun(ctx context.Context, client *github.Client, _ return mcp.NewToolResultText(string(r)), nil } -func getActionsResourceWorkflowRuns(ctx context.Context, client *github.Client, request mcp.CallToolRequest, owner, repo string, resourceID int64, pagination PaginationParams) (*mcp.CallToolResult, error) { +func listWorkflowRuns(ctx context.Context, client *github.Client, request mcp.CallToolRequest, owner, repo string, resourceID int64, pagination PaginationParams) (*mcp.CallToolResult, error) { filterArgs, err := OptionalParam[map[string]any](request, "workflow_runs_filter") if err != nil { return mcp.NewToolResultError(err.Error()), nil @@ -290,7 +295,7 @@ func getActionsResourceWorkflowRuns(ctx context.Context, client *github.Client, return mcp.NewToolResultText(string(r)), nil } -func getActionsResourceWorkflowJob(ctx context.Context, client *github.Client, _ mcp.CallToolRequest, owner, repo string, resourceID int64) (*mcp.CallToolResult, error) { +func getWorkflowJob(ctx context.Context, client *github.Client, _ mcp.CallToolRequest, owner, repo string, resourceID int64) (*mcp.CallToolResult, error) { workflowJob, resp, err := client.Actions.GetWorkflowJobByID(ctx, owner, repo, resourceID) if err != nil { return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to get workflow job", resp, err), nil @@ -303,7 +308,7 @@ func getActionsResourceWorkflowJob(ctx context.Context, client *github.Client, _ return mcp.NewToolResultText(string(r)), nil } -func getActionsResourceWorkflowJobs(ctx context.Context, client *github.Client, request mcp.CallToolRequest, owner, repo string, resourceID int64, pagination PaginationParams) (*mcp.CallToolResult, error) { +func listWorkflowJobs(ctx context.Context, client *github.Client, request mcp.CallToolRequest, owner, repo string, resourceID int64, pagination PaginationParams) (*mcp.CallToolResult, error) { filterArgs, err := OptionalParam[map[string]any](request, "workflow_jobs_filter") if err != nil { return mcp.NewToolResultError(err.Error()), nil @@ -329,8 +334,14 @@ func getActionsResourceWorkflowJobs(ctx context.Context, client *github.Client, return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to list workflow jobs", resp, err), nil } + // Add optimization tip for failed job debugging + response := map[string]any{ + "jobs": workflowJobs, + "optimization_tip": "For debugging failed jobs, consider using get_job_logs with failed_only=true and run_id=" + fmt.Sprintf("%d", resourceID) + " to get logs directly without needing to list jobs first", + } + defer func() { _ = resp.Body.Close() }() - r, err := json.Marshal(workflowJobs) + r, err := json.Marshal(response) if err != nil { return nil, fmt.Errorf("failed to marshal workflow jobs: %w", err) } @@ -338,7 +349,7 @@ func getActionsResourceWorkflowJobs(ctx context.Context, client *github.Client, return mcp.NewToolResultText(string(r)), nil } -func getActionsResourceDownloadWorkflowArtifact(ctx context.Context, client *github.Client, _ mcp.CallToolRequest, owner, repo string, resourceID int64) (*mcp.CallToolResult, error) { +func downloadWorkflowArtifact(ctx context.Context, client *github.Client, _ mcp.CallToolRequest, owner, repo string, resourceID int64) (*mcp.CallToolResult, error) { // Get the download URL for the artifact url, resp, err := client.Actions.DownloadArtifact(ctx, owner, repo, resourceID, 1) if err != nil { @@ -362,7 +373,7 @@ func getActionsResourceDownloadWorkflowArtifact(ctx context.Context, client *git return mcp.NewToolResultText(string(r)), nil } -func getActionsResourceWorkflowArtifacts(ctx context.Context, client *github.Client, _ mcp.CallToolRequest, owner, repo string, resourceID int64, pagination PaginationParams) (*mcp.CallToolResult, error) { +func listWorkflowArtifacts(ctx context.Context, client *github.Client, _ mcp.CallToolRequest, owner, repo string, resourceID int64, pagination PaginationParams) (*mcp.CallToolResult, error) { // Set up list options opts := &github.ListOptions{ PerPage: pagination.PerPage, @@ -443,145 +454,6 @@ func ListWorkflows(getClient GetClientFn, t translations.TranslationHelperFunc) } } -// ListWorkflowRuns creates a tool to list workflow runs for a specific workflow -func ListWorkflowRuns(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { - return mcp.NewTool("list_workflow_runs", - mcp.WithDescription(t("TOOL_LIST_WORKFLOW_RUNS_DESCRIPTION", "List workflow runs for a specific workflow")), - mcp.WithToolAnnotation(mcp.ToolAnnotation{ - Title: t("TOOL_LIST_WORKFLOW_RUNS_USER_TITLE", "List workflow runs"), - ReadOnlyHint: ToBoolPtr(true), - }), - mcp.WithString("owner", - mcp.Required(), - mcp.Description(DescriptionRepositoryOwner), - ), - mcp.WithString("repo", - mcp.Required(), - mcp.Description(DescriptionRepositoryName), - ), - mcp.WithString("workflow_id", - mcp.Required(), - mcp.Description("The workflow ID or workflow file name"), - ), - mcp.WithString("actor", - mcp.Description("Returns someone's workflow runs. Use the login for the user who created the workflow run."), - ), - mcp.WithString("branch", - mcp.Description("Returns workflow runs associated with a branch. Use the name of the branch."), - ), - mcp.WithString("event", - mcp.Description("Returns workflow runs for a specific event type"), - mcp.Enum( - "branch_protection_rule", - "check_run", - "check_suite", - "create", - "delete", - "deployment", - "deployment_status", - "discussion", - "discussion_comment", - "fork", - "gollum", - "issue_comment", - "issues", - "label", - "merge_group", - "milestone", - "page_build", - "public", - "pull_request", - "pull_request_review", - "pull_request_review_comment", - "pull_request_target", - "push", - "registry_package", - "release", - "repository_dispatch", - "schedule", - "status", - "watch", - "workflow_call", - "workflow_dispatch", - "workflow_run", - ), - ), - mcp.WithString("status", - mcp.Description("Returns workflow runs with the check run status"), - mcp.Enum("queued", "in_progress", "completed", "requested", "waiting"), - ), - WithPagination(), - ), - 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, "workflow_id") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - - // Get optional filtering parameters - actor, err := OptionalParam[string](request, "actor") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - branch, err := OptionalParam[string](request, "branch") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - event, err := OptionalParam[string](request, "event") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - status, err := OptionalParam[string](request, "status") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - - // Get optional pagination parameters - pagination, err := OptionalPaginationParams(request) - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - - client, err := getClient(ctx) - if err != nil { - return nil, fmt.Errorf("failed to get GitHub client: %w", err) - } - - // Set up list options - opts := &github.ListWorkflowRunsOptions{ - Actor: actor, - Branch: branch, - Event: event, - Status: status, - ListOptions: github.ListOptions{ - PerPage: pagination.PerPage, - Page: pagination.Page, - }, - } - - workflowRuns, resp, err := client.Actions.ListWorkflowRunsByFileName(ctx, owner, repo, workflowID, opts) - if err != nil { - return nil, fmt.Errorf("failed to list workflow runs: %w", err) - } - defer func() { _ = resp.Body.Close() }() - - r, err := json.Marshal(workflowRuns) - if err != nil { - return nil, fmt.Errorf("failed to marshal response: %w", err) - } - - return mcp.NewToolResultText(string(r)), nil - } -} - // 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", @@ -681,62 +553,6 @@ func RunWorkflow(getClient GetClientFn, t translations.TranslationHelperFunc) (t } } -// GetWorkflowRun creates a tool to get details of a specific workflow run -func GetWorkflowRun(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { - return mcp.NewTool("get_workflow_run", - mcp.WithDescription(t("TOOL_GET_WORKFLOW_RUN_DESCRIPTION", "Get details of a specific workflow run")), - mcp.WithToolAnnotation(mcp.ToolAnnotation{ - Title: t("TOOL_GET_WORKFLOW_RUN_USER_TITLE", "Get workflow run"), - ReadOnlyHint: ToBoolPtr(true), - }), - mcp.WithString("owner", - mcp.Required(), - mcp.Description(DescriptionRepositoryOwner), - ), - mcp.WithString("repo", - mcp.Required(), - mcp.Description(DescriptionRepositoryName), - ), - mcp.WithNumber("run_id", - mcp.Required(), - mcp.Description("The unique identifier of the workflow run"), - ), - ), - 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 - } - runIDInt, err := RequiredInt(request, "run_id") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - runID := int64(runIDInt) - - client, err := getClient(ctx) - if err != nil { - return nil, fmt.Errorf("failed to get GitHub client: %w", err) - } - - workflowRun, resp, err := client.Actions.GetWorkflowRunByID(ctx, owner, repo, runID) - if err != nil { - return nil, fmt.Errorf("failed to get workflow run: %w", err) - } - defer func() { _ = resp.Body.Close() }() - - r, err := json.Marshal(workflowRun) - if err != nil { - return nil, fmt.Errorf("failed to marshal response: %w", err) - } - - return mcp.NewToolResultText(string(r)), nil - } -} - // GetWorkflowRunLogs creates a tool to download logs for a specific workflow run func GetWorkflowRunLogs(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { return mcp.NewTool("get_workflow_run_logs", @@ -803,94 +619,6 @@ func GetWorkflowRunLogs(getClient GetClientFn, t translations.TranslationHelperF } } -// ListWorkflowJobs creates a tool to list jobs for a specific workflow run -func ListWorkflowJobs(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { - return mcp.NewTool("list_workflow_jobs", - mcp.WithDescription(t("TOOL_LIST_WORKFLOW_JOBS_DESCRIPTION", "List jobs for a specific workflow run")), - mcp.WithToolAnnotation(mcp.ToolAnnotation{ - Title: t("TOOL_LIST_WORKFLOW_JOBS_USER_TITLE", "List workflow jobs"), - ReadOnlyHint: ToBoolPtr(true), - }), - mcp.WithString("owner", - mcp.Required(), - mcp.Description(DescriptionRepositoryOwner), - ), - mcp.WithString("repo", - mcp.Required(), - mcp.Description(DescriptionRepositoryName), - ), - mcp.WithNumber("run_id", - mcp.Required(), - mcp.Description("The unique identifier of the workflow run"), - ), - mcp.WithString("filter", - mcp.Description("Filters jobs by their completed_at timestamp"), - mcp.Enum("latest", "all"), - ), - WithPagination(), - ), - 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 - } - runIDInt, err := RequiredInt(request, "run_id") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - runID := int64(runIDInt) - - // Get optional filtering parameters - filter, err := OptionalParam[string](request, "filter") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - - // Get optional pagination parameters - pagination, err := OptionalPaginationParams(request) - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - - client, err := getClient(ctx) - if err != nil { - return nil, fmt.Errorf("failed to get GitHub client: %w", err) - } - - // Set up list options - opts := &github.ListWorkflowJobsOptions{ - Filter: filter, - ListOptions: github.ListOptions{ - PerPage: pagination.PerPage, - Page: pagination.Page, - }, - } - - jobs, resp, err := client.Actions.ListWorkflowJobs(ctx, owner, repo, runID, opts) - if err != nil { - return nil, fmt.Errorf("failed to list workflow jobs: %w", err) - } - defer func() { _ = resp.Body.Close() }() - - // Add optimization tip for failed job debugging - response := map[string]any{ - "jobs": jobs, - "optimization_tip": "For debugging failed jobs, consider using get_job_logs with failed_only=true and run_id=" + fmt.Sprintf("%d", runID) + " to get logs directly without needing to list jobs first", - } - - r, err := json.Marshal(response) - if err != nil { - return nil, fmt.Errorf("failed to marshal response: %w", err) - } - - return mcp.NewToolResultText(string(r)), nil - } -} - // GetJobLogs creates a tool to download logs for a specific workflow job or efficiently get all failed job logs for a workflow run func GetJobLogs(getClient GetClientFn, t translations.TranslationHelperFunc, contentWindowSize int) (tool mcp.Tool, handler server.ToolHandlerFunc) { return mcp.NewTool("get_job_logs", @@ -1330,140 +1058,6 @@ func CancelWorkflowRun(getClient GetClientFn, t translations.TranslationHelperFu } } -// ListWorkflowRunArtifacts creates a tool to list artifacts for a workflow run -func ListWorkflowRunArtifacts(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { - return mcp.NewTool("list_workflow_run_artifacts", - mcp.WithDescription(t("TOOL_LIST_WORKFLOW_RUN_ARTIFACTS_DESCRIPTION", "List artifacts for a workflow run")), - mcp.WithToolAnnotation(mcp.ToolAnnotation{ - Title: t("TOOL_LIST_WORKFLOW_RUN_ARTIFACTS_USER_TITLE", "List workflow artifacts"), - ReadOnlyHint: ToBoolPtr(true), - }), - mcp.WithString("owner", - mcp.Required(), - mcp.Description(DescriptionRepositoryOwner), - ), - mcp.WithString("repo", - mcp.Required(), - mcp.Description(DescriptionRepositoryName), - ), - mcp.WithNumber("run_id", - mcp.Required(), - mcp.Description("The unique identifier of the workflow run"), - ), - WithPagination(), - ), - 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 - } - runIDInt, err := RequiredInt(request, "run_id") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - runID := int64(runIDInt) - - // Get optional pagination parameters - pagination, err := OptionalPaginationParams(request) - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - - client, err := getClient(ctx) - if err != nil { - return nil, fmt.Errorf("failed to get GitHub client: %w", err) - } - - // Set up list options - opts := &github.ListOptions{ - PerPage: pagination.PerPage, - Page: pagination.Page, - } - - artifacts, resp, err := client.Actions.ListWorkflowRunArtifacts(ctx, owner, repo, runID, opts) - if err != nil { - return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to list workflow run artifacts", resp, err), nil - } - defer func() { _ = resp.Body.Close() }() - - r, err := json.Marshal(artifacts) - if err != nil { - return nil, fmt.Errorf("failed to marshal response: %w", err) - } - - return mcp.NewToolResultText(string(r)), nil - } -} - -// DownloadWorkflowRunArtifact creates a tool to download a workflow run artifact -func DownloadWorkflowRunArtifact(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { - return mcp.NewTool("download_workflow_run_artifact", - mcp.WithDescription(t("TOOL_DOWNLOAD_WORKFLOW_RUN_ARTIFACT_DESCRIPTION", "Get download URL for a workflow run artifact")), - mcp.WithToolAnnotation(mcp.ToolAnnotation{ - Title: t("TOOL_DOWNLOAD_WORKFLOW_RUN_ARTIFACT_USER_TITLE", "Download workflow artifact"), - ReadOnlyHint: ToBoolPtr(true), - }), - mcp.WithString("owner", - mcp.Required(), - mcp.Description(DescriptionRepositoryOwner), - ), - mcp.WithString("repo", - mcp.Required(), - mcp.Description(DescriptionRepositoryName), - ), - mcp.WithNumber("artifact_id", - mcp.Required(), - mcp.Description("The unique identifier of the artifact"), - ), - ), - 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 - } - artifactIDInt, err := RequiredInt(request, "artifact_id") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - artifactID := int64(artifactIDInt) - - client, err := getClient(ctx) - if err != nil { - return nil, fmt.Errorf("failed to get GitHub client: %w", err) - } - - // Get the download URL for the artifact - url, resp, err := client.Actions.DownloadArtifact(ctx, owner, repo, artifactID, 1) - if err != nil { - return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to get artifact download URL", resp, err), nil - } - defer func() { _ = resp.Body.Close() }() - - // Create response with the download URL and information - result := map[string]any{ - "download_url": url.String(), - "message": "Artifact is available for download", - "note": "The download_url provides a download link for the artifact as a ZIP archive. The link is temporary and expires after a short time.", - "artifact_id": artifactID, - } - - r, err := json.Marshal(result) - if err != nil { - return nil, fmt.Errorf("failed to marshal response: %w", err) - } - - return mcp.NewToolResultText(string(r)), nil - } -} - // DeleteWorkflowRunLogs creates a tool to delete logs for a workflow run func DeleteWorkflowRunLogs(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { return mcp.NewTool("delete_workflow_run_logs", @@ -1529,57 +1123,17 @@ func DeleteWorkflowRunLogs(getClient GetClientFn, t translations.TranslationHelp } // GetWorkflowRunUsage creates a tool to get usage metrics for a workflow run -func GetWorkflowRunUsage(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { - return mcp.NewTool("get_workflow_run_usage", - mcp.WithDescription(t("TOOL_GET_WORKFLOW_RUN_USAGE_DESCRIPTION", "Get usage metrics for a workflow run")), - mcp.WithToolAnnotation(mcp.ToolAnnotation{ - Title: t("TOOL_GET_WORKFLOW_RUN_USAGE_USER_TITLE", "Get workflow usage"), - ReadOnlyHint: ToBoolPtr(true), - }), - mcp.WithString("owner", - mcp.Required(), - mcp.Description(DescriptionRepositoryOwner), - ), - mcp.WithString("repo", - mcp.Required(), - mcp.Description(DescriptionRepositoryName), - ), - mcp.WithNumber("run_id", - mcp.Required(), - mcp.Description("The unique identifier of the workflow run"), - ), - ), - 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 - } - runIDInt, err := RequiredInt(request, "run_id") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - runID := int64(runIDInt) - - client, err := getClient(ctx) - if err != nil { - return nil, fmt.Errorf("failed to get GitHub client: %w", err) - } - - usage, resp, err := client.Actions.GetWorkflowRunUsageByID(ctx, owner, repo, runID) - if err != nil { - return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to get workflow run usage", resp, err), nil - } - defer func() { _ = resp.Body.Close() }() +func getWorkflowRunUsage(ctx context.Context, client *github.Client, request mcp.CallToolRequest, owner, repo string, resourceID int64) (*mcp.CallToolResult, error) { + usage, resp, err := client.Actions.GetWorkflowRunUsageByID(ctx, owner, repo, resourceID) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to get workflow run usage", resp, err), nil + } + defer func() { _ = resp.Body.Close() }() - r, err := json.Marshal(usage) - if err != nil { - return nil, fmt.Errorf("failed to marshal response: %w", err) - } + r, err := json.Marshal(usage) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } - return mcp.NewToolResultText(string(r)), nil - } + return mcp.NewToolResultText(string(r)), nil } diff --git a/pkg/github/tools.go b/pkg/github/tools.go index f994a1e11..377252283 100644 --- a/pkg/github/tools.go +++ b/pkg/github/tools.go @@ -259,12 +259,12 @@ func DefaultToolsetGroup(readOnly bool, getClient GetClientFn, getGQLClient GetG toolsets.NewServerTool(ListWorkflows(getClient, t)), // toolsets.NewServerTool(ListWorkflowRuns(getClient, t)), // toolsets.NewServerTool(GetWorkflowRun(getClient, t)), - // toolsets.NewServerTool(GetWorkflowRunLogs(getClient, t)), + toolsets.NewServerTool(GetWorkflowRunLogs(getClient, t)), // toolsets.NewServerTool(ListWorkflowJobs(getClient, t)), toolsets.NewServerTool(GetJobLogs(getClient, t, contentWindowSize)), // toolsets.NewServerTool(ListWorkflowRunArtifacts(getClient, t)), // toolsets.NewServerTool(DownloadWorkflowRunArtifact(getClient, t)), - toolsets.NewServerTool(GetWorkflowRunUsage(getClient, t)), + // toolsets.NewServerTool(GetWorkflowRunUsage(getClient, t)), ). AddWriteTools( toolsets.NewServerTool(RunWorkflow(getClient, t)), From 3796906a46bfbe82fb6854eaba0fcc48c14bccc8 Mon Sep 17 00:00:00 2001 From: Adam Holt Date: Mon, 20 Oct 2025 11:47:16 +0200 Subject: [PATCH 06/22] fix tests from rename --- pkg/github/actions_test.go | 174 +++++++++++-------------------------- 1 file changed, 49 insertions(+), 125 deletions(-) diff --git a/pkg/github/actions_test.go b/pkg/github/actions_test.go index 21fbd23bf..ead677d56 100644 --- a/pkg/github/actions_test.go +++ b/pkg/github/actions_test.go @@ -415,19 +415,6 @@ func Test_CancelWorkflowRun(t *testing.T) { } func Test_ListWorkflowRunArtifacts(t *testing.T) { - // Verify tool definition once - mockClient := github.NewClient(nil) - tool, _ := ListWorkflowRunArtifacts(stubGetClientFn(mockClient), translations.NullTranslationHelper) - - assert.Equal(t, "list_workflow_run_artifacts", 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, "run_id") - assert.Contains(t, tool.InputSchema.Properties, "perPage") - assert.Contains(t, tool.InputSchema.Properties, "page") - assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "run_id"}) - tests := []struct { name string mockedClient *http.Client @@ -490,21 +477,23 @@ func Test_ListWorkflowRunArtifacts(t *testing.T) { ), ), requestArgs: map[string]any{ - "owner": "owner", - "repo": "repo", - "run_id": float64(12345), + "action": "list_workflow_artifacts", + "owner": "owner", + "repo": "repo", + "resource_id": float64(12345), }, expectError: false, }, { - name: "missing required parameter run_id", + name: "missing required parameter resource_id", mockedClient: mock.NewMockedHTTPClient(), requestArgs: map[string]any{ - "owner": "owner", - "repo": "repo", + "action": "list_workflow_artifacts", + "owner": "owner", + "repo": "repo", }, expectError: true, - expectedErrMsg: "missing required parameter: run_id", + expectedErrMsg: "missing required parameter: resource_id", }, } @@ -512,7 +501,7 @@ func Test_ListWorkflowRunArtifacts(t *testing.T) { t.Run(tc.name, func(t *testing.T) { // Setup client with mock client := github.NewClient(tc.mockedClient) - _, handler := ListWorkflowRunArtifacts(stubGetClientFn(client), translations.NullTranslationHelper) + _, handler := ActionsRead(stubGetClientFn(client), translations.NullTranslationHelper) // Create call request request := createMCPRequest(tc.requestArgs) @@ -543,17 +532,6 @@ func Test_ListWorkflowRunArtifacts(t *testing.T) { } func Test_DownloadWorkflowRunArtifact(t *testing.T) { - // Verify tool definition once - mockClient := github.NewClient(nil) - tool, _ := DownloadWorkflowRunArtifact(stubGetClientFn(mockClient), translations.NullTranslationHelper) - - assert.Equal(t, "download_workflow_run_artifact", 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, "artifact_id") - assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "artifact_id"}) - tests := []struct { name string mockedClient *http.Client @@ -577,21 +555,23 @@ func Test_DownloadWorkflowRunArtifact(t *testing.T) { ), ), requestArgs: map[string]any{ + "action": "download_workflow_artifact", "owner": "owner", "repo": "repo", - "artifact_id": float64(123), + "resource_id": float64(123), }, expectError: false, }, { - name: "missing required parameter artifact_id", + name: "missing required parameter resource_id", mockedClient: mock.NewMockedHTTPClient(), requestArgs: map[string]any{ - "owner": "owner", - "repo": "repo", + "action": "download_workflow_artifact", + "owner": "owner", + "repo": "repo", }, expectError: true, - expectedErrMsg: "missing required parameter: artifact_id", + expectedErrMsg: "missing required parameter: resource_id", }, } @@ -599,7 +579,7 @@ func Test_DownloadWorkflowRunArtifact(t *testing.T) { t.Run(tc.name, func(t *testing.T) { // Setup client with mock client := github.NewClient(tc.mockedClient) - _, handler := DownloadWorkflowRunArtifact(stubGetClientFn(client), translations.NullTranslationHelper) + _, handler := ActionsRead(stubGetClientFn(client), translations.NullTranslationHelper) // Create call request request := createMCPRequest(tc.requestArgs) @@ -631,17 +611,6 @@ func Test_DownloadWorkflowRunArtifact(t *testing.T) { } func Test_DeleteWorkflowRunLogs(t *testing.T) { - // Verify tool definition once - mockClient := github.NewClient(nil) - tool, _ := DeleteWorkflowRunLogs(stubGetClientFn(mockClient), translations.NullTranslationHelper) - - assert.Equal(t, "delete_workflow_run_logs", 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, "run_id") - assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "run_id"}) - tests := []struct { name string mockedClient *http.Client @@ -712,17 +681,6 @@ func Test_DeleteWorkflowRunLogs(t *testing.T) { } func Test_GetWorkflowRunUsage(t *testing.T) { - // Verify tool definition once - mockClient := github.NewClient(nil) - tool, _ := GetWorkflowRunUsage(stubGetClientFn(mockClient), translations.NullTranslationHelper) - - assert.Equal(t, "get_workflow_run_usage", 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, "run_id") - assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "run_id"}) - tests := []struct { name string mockedClient *http.Client @@ -761,9 +719,10 @@ func Test_GetWorkflowRunUsage(t *testing.T) { ), ), requestArgs: map[string]any{ - "owner": "owner", - "repo": "repo", - "run_id": float64(12345), + "action": "get_workflow_run_usage", + "owner": "owner", + "repo": "repo", + "resource_id": float64(12345), }, expectError: false, }, @@ -771,11 +730,12 @@ func Test_GetWorkflowRunUsage(t *testing.T) { name: "missing required parameter run_id", mockedClient: mock.NewMockedHTTPClient(), requestArgs: map[string]any{ - "owner": "owner", - "repo": "repo", + "action": "get_workflow_run_usage", + "owner": "owner", + "repo": "repo", }, expectError: true, - expectedErrMsg: "missing required parameter: run_id", + expectedErrMsg: "missing required parameter: resource_id", }, } @@ -783,7 +743,7 @@ func Test_GetWorkflowRunUsage(t *testing.T) { t.Run(tc.name, func(t *testing.T) { // Setup client with mock client := github.NewClient(tc.mockedClient) - _, handler := GetWorkflowRunUsage(stubGetClientFn(client), translations.NullTranslationHelper) + _, handler := ActionsRead(stubGetClientFn(client), translations.NullTranslationHelper) // Create call request request := createMCPRequest(tc.requestArgs) @@ -1328,11 +1288,11 @@ func Test_ActionsResourceRead(t *testing.T) { assert.Equal(t, "actions_resource_read", tool.Name) assert.NotEmpty(t, tool.Description) - assert.Contains(t, tool.InputSchema.Properties, "resource") + assert.Contains(t, tool.InputSchema.Properties, "action") assert.Contains(t, tool.InputSchema.Properties, "owner") assert.Contains(t, tool.InputSchema.Properties, "repo") assert.Contains(t, tool.InputSchema.Properties, "resource_id") - assert.ElementsMatch(t, tool.InputSchema.Required, []string{"resource", "owner", "repo", "resource_id"}) + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"action", "owner", "repo", "resource_id"}) tests := []struct { name string @@ -1342,7 +1302,7 @@ func Test_ActionsResourceRead(t *testing.T) { expectedErrMsg string }{ { - name: "missing required parameter resource", + name: "missing required parameter action", mockedClient: mock.NewMockedHTTPClient(), requestArgs: map[string]any{ "owner": "owner", @@ -1350,13 +1310,13 @@ func Test_ActionsResourceRead(t *testing.T) { "resource_id": float64(123), }, expectError: true, - expectedErrMsg: "missing required parameter: resource", + expectedErrMsg: "missing required parameter: action", }, { name: "missing required parameter owner", mockedClient: mock.NewMockedHTTPClient(), requestArgs: map[string]any{ - "resource": "workflow", + "action": "get_workflow", "repo": "repo", "resource_id": float64(123), }, @@ -1367,7 +1327,7 @@ func Test_ActionsResourceRead(t *testing.T) { name: "missing required parameter repo", mockedClient: mock.NewMockedHTTPClient(), requestArgs: map[string]any{ - "resource": "workflow", + "action": "get_workflow", "owner": "owner", "resource_id": float64(123), }, @@ -1378,9 +1338,9 @@ func Test_ActionsResourceRead(t *testing.T) { name: "missing required parameter resource_id", mockedClient: mock.NewMockedHTTPClient(), requestArgs: map[string]any{ - "owner": "owner", - "repo": "repo", - "resource": "workflow", + "action": "get_workflow", + "owner": "owner", + "repo": "repo", }, expectError: true, expectedErrMsg: "missing required parameter: resource_id", @@ -1389,13 +1349,13 @@ func Test_ActionsResourceRead(t *testing.T) { name: "unknown resource", mockedClient: mock.NewMockedHTTPClient(), requestArgs: map[string]any{ - "resource": "random", + "action": "random", "owner": "owner", "repo": "repo", "resource_id": float64(123), }, expectError: true, - expectedErrMsg: "unknown resource type: random", + expectedErrMsg: "unknown action: random", }, } @@ -1425,19 +1385,7 @@ func Test_ActionsResourceRead(t *testing.T) { } } -func Test_ActionsResourceRead_Workflow(t *testing.T) { - // Verify tool definition once - mockClient := github.NewClient(nil) - tool, _ := ActionsRead(stubGetClientFn(mockClient), translations.NullTranslationHelper) - - assert.Equal(t, "actions_resource_read", tool.Name) - assert.NotEmpty(t, tool.Description) - assert.Contains(t, tool.InputSchema.Properties, "resource") - assert.Contains(t, tool.InputSchema.Properties, "owner") - assert.Contains(t, tool.InputSchema.Properties, "repo") - assert.Contains(t, tool.InputSchema.Properties, "resource_id") - assert.ElementsMatch(t, tool.InputSchema.Required, []string{"resource", "owner", "repo", "resource_id"}) - +func Test_ActionsResourceRead_GetWorkflow(t *testing.T) { tests := []struct { name string mockedClient *http.Client @@ -1465,7 +1413,7 @@ func Test_ActionsResourceRead_Workflow(t *testing.T) { ), ), requestArgs: map[string]any{ - "resource": "workflow", + "action": "get_workflow", "owner": "owner", "repo": "repo", "resource_id": float64(1), @@ -1483,7 +1431,7 @@ func Test_ActionsResourceRead_Workflow(t *testing.T) { ), ), requestArgs: map[string]any{ - "resource": "workflow", + "action": "get_workflow", "owner": "owner", "repo": "repo", "resource_id": float64(2), @@ -1532,19 +1480,7 @@ func Test_ActionsResourceRead_Workflow(t *testing.T) { } } -func Test_ActionsResourceRead_WorkflowRun(t *testing.T) { - // Verify tool definition once - mockClient := github.NewClient(nil) - tool, _ := ActionsRead(stubGetClientFn(mockClient), translations.NullTranslationHelper) - - assert.Equal(t, "actions_resource_read", tool.Name) - assert.NotEmpty(t, tool.Description) - assert.Contains(t, tool.InputSchema.Properties, "resource") - assert.Contains(t, tool.InputSchema.Properties, "owner") - assert.Contains(t, tool.InputSchema.Properties, "repo") - assert.Contains(t, tool.InputSchema.Properties, "resource_id") - assert.ElementsMatch(t, tool.InputSchema.Required, []string{"resource", "owner", "repo", "resource_id"}) - +func Test_ActionsResourceRead_GetWorkflowRun(t *testing.T) { tests := []struct { name string mockedClient *http.Client @@ -1572,7 +1508,7 @@ func Test_ActionsResourceRead_WorkflowRun(t *testing.T) { ), ), requestArgs: map[string]any{ - "resource": "workflow_run", + "action": "get_workflow_run", "owner": "owner", "repo": "repo", "resource_id": float64(12345), @@ -1590,7 +1526,7 @@ func Test_ActionsResourceRead_WorkflowRun(t *testing.T) { ), ), requestArgs: map[string]any{ - "resource": "workflow_run", + "action": "get_workflow_run", "owner": "owner", "repo": "repo", "resource_id": float64(99999), @@ -1641,19 +1577,7 @@ func Test_ActionsResourceRead_WorkflowRun(t *testing.T) { } } -func Test_ActionsResourceRead_WorkflowRuns(t *testing.T) { - // Verify tool definition once - mockClient := github.NewClient(nil) - tool, _ := ActionsRead(stubGetClientFn(mockClient), translations.NullTranslationHelper) - - assert.Equal(t, "actions_resource_read", tool.Name) - assert.NotEmpty(t, tool.Description) - assert.Contains(t, tool.InputSchema.Properties, "resource") - assert.Contains(t, tool.InputSchema.Properties, "owner") - assert.Contains(t, tool.InputSchema.Properties, "repo") - assert.Contains(t, tool.InputSchema.Properties, "resource_id") - assert.ElementsMatch(t, tool.InputSchema.Required, []string{"resource", "owner", "repo", "resource_id"}) - +func Test_ActionsResourceRead_ListWorkflowRuns(t *testing.T) { tests := []struct { name string mockedClient *http.Client @@ -1693,7 +1617,7 @@ func Test_ActionsResourceRead_WorkflowRuns(t *testing.T) { ), ), requestArgs: map[string]any{ - "resource": "workflow_runs", + "action": "list_workflow_runs", "owner": "owner", "repo": "repo", "resource_id": float64(1), @@ -1732,11 +1656,11 @@ func Test_ActionsResourceRead_WorkflowRuns(t *testing.T) { ), ), requestArgs: map[string]any{ - "resource": "workflow_runs", + "action": "list_workflow_runs", "owner": "owner", "repo": "repo", "resource_id": float64(1), - "workflow_runs_filter": map[string]string{ + "workflow_runs_filter": map[string]any{ "actor": "omgitsads", "status": "completed", }, @@ -1754,7 +1678,7 @@ func Test_ActionsResourceRead_WorkflowRuns(t *testing.T) { ), ), requestArgs: map[string]any{ - "resource": "workflow_runs", + "action": "list_workflow_runs", "owner": "owner", "repo": "repo", "resource_id": float64(99999), From ea7b19b8c2baa6e562f495eaec2ae8648eb72091 Mon Sep 17 00:00:00 2001 From: Adam Holt Date: Tue, 21 Oct 2025 11:00:43 +0200 Subject: [PATCH 07/22] More tests --- pkg/github/actions_test.go | 263 +++++++++++++++++++++++++++++++++++++ 1 file changed, 263 insertions(+) diff --git a/pkg/github/actions_test.go b/pkg/github/actions_test.go index ead677d56..8ba013a18 100644 --- a/pkg/github/actions_test.go +++ b/pkg/github/actions_test.go @@ -1718,3 +1718,266 @@ func Test_ActionsResourceRead_ListWorkflowRuns(t *testing.T) { }) } } + +func Test_ActionsResourceRead_GetWorkflowJob(t *testing.T) { + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]any + expectError bool + expectedErrMsg string + expectedErrMsgRegexp *regexp.Regexp + }{ + { + name: "successful workflow job read", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposActionsJobsByOwnerByRepoByJobId, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + job := &github.WorkflowJob{ + ID: github.Ptr(int64(12345)), + RunID: github.Ptr(int64(1)), + Status: github.Ptr("completed"), + Conclusion: github.Ptr("success"), + } + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(job) + }), + ), + ), + requestArgs: map[string]any{ + "action": "get_workflow_job", + "owner": "owner", + "repo": "repo", + "resource_id": float64(12345), + }, + expectError: false, + }, + { + name: "missing workflow job read", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposActionsJobsByOwnerByRepoByJobId, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + }), + ), + ), + requestArgs: map[string]any{ + "action": "get_workflow_job", + "owner": "owner", + "repo": "repo", + "resource_id": float64(99999), + }, + expectError: true, + expectedErrMsgRegexp: regexp.MustCompile(`^failed to get workflow job: GET .*/repos/owner/repo/actions/jobs/99999: 404.*$`), + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Setup client with mock + client := github.NewClient(tc.mockedClient) + _, handler := ActionsRead(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.Contains(t, tc.expectedErrMsg, textContent.Text) + return + } + + if tc.expectedErrMsgRegexp != nil { + assert.Regexp(t, tc.expectedErrMsgRegexp, textContent.Text) + return + } + }) + } +} + +func Test_ActionsResourceRead_ListWorkflowJobs(t *testing.T) { + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]any + expectError bool + expectedErrMsg string + expectedErrMsgRegexp *regexp.Regexp + }{ + { + name: "successful workflow jobs read", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposActionsRunsJobsByOwnerByRepoByRunId, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + jobs := &github.Jobs{ + TotalCount: github.Ptr(2), + Jobs: []*github.WorkflowJob{ + { + ID: github.Ptr(int64(12345)), + RunID: github.Ptr(int64(1)), + Status: github.Ptr("completed"), + Conclusion: github.Ptr("success"), + }, + { + ID: github.Ptr(int64(12346)), + RunID: github.Ptr(int64(1)), + Status: github.Ptr("completed"), + Conclusion: github.Ptr("failure"), + }, + }, + } + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(jobs) + }), + ), + ), + requestArgs: map[string]any{ + "action": "list_workflow_jobs", + "owner": "owner", + "repo": "repo", + "resource_id": float64(1), + }, + expectError: false, + }, + { + name: "missing workflow runs read", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposActionsRunsJobsByOwnerByRepoByRunId, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + }), + ), + ), + requestArgs: map[string]any{ + "action": "list_workflow_jobs", + "owner": "owner", + "repo": "repo", + "resource_id": float64(99999), + }, + expectError: true, + expectedErrMsgRegexp: regexp.MustCompile(`^failed to list workflow jobs: GET .*/repos/owner/repo/actions/runs/99999/jobs.* 404.*$`), + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Setup client with mock + client := github.NewClient(tc.mockedClient) + _, handler := ActionsRead(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.Contains(t, tc.expectedErrMsg, textContent.Text) + return + } + + if tc.expectedErrMsgRegexp != nil { + assert.Regexp(t, tc.expectedErrMsgRegexp, textContent.Text) + return + } + }) + } +} + +func Test_ActionsResourceRead_DownloadWorkflowArtifact(t *testing.T) { + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]any + expectError bool + expectedErrMsg string + expectedErrMsgRegexp *regexp.Regexp + }{ + { + name: "successful workflow artifact download", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposActionsArtifactsByOwnerByRepoByArtifactIdByArchiveFormat, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusFound) + w.Header().Set("Location", "https://github.com/artifact/download/url") + }), + ), + ), + requestArgs: map[string]any{ + "action": "download_workflow_artifact", + "owner": "owner", + "repo": "repo", + "resource_id": float64(12345), + }, + expectError: false, + }, + { + name: "missing workflow artifact download", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposActionsArtifactsByOwnerByRepoByArtifactId, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + }), + ), + ), + requestArgs: map[string]any{ + "action": "download_workflow_artifact", + "owner": "owner", + "repo": "repo", + "resource_id": float64(99999), + }, + expectError: true, + expectedErrMsg: "failed to get artifact download URL: unexpected status code: 404 Not Found", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Setup client with mock + client := github.NewClient(tc.mockedClient) + _, handler := ActionsRead(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.Contains(t, tc.expectedErrMsg, textContent.Text) + return + } + + if tc.expectedErrMsgRegexp != nil { + assert.Regexp(t, tc.expectedErrMsgRegexp, textContent.Text) + return + } + }) + } +} From ce0ab9cb992de75934d485b735b16c93ab7762d5 Mon Sep 17 00:00:00 2001 From: Adam Holt Date: Fri, 24 Oct 2025 14:03:28 +0200 Subject: [PATCH 08/22] Tests for artifacts and usage --- pkg/github/actions_test.go | 188 +++++++++++++++++++++++++++++++++++++ 1 file changed, 188 insertions(+) diff --git a/pkg/github/actions_test.go b/pkg/github/actions_test.go index 8ba013a18..8add2a8d6 100644 --- a/pkg/github/actions_test.go +++ b/pkg/github/actions_test.go @@ -1981,3 +1981,191 @@ func Test_ActionsResourceRead_DownloadWorkflowArtifact(t *testing.T) { }) } } + +func Test_ActionsResourceRead_ListWorkflowArtifacts(t *testing.T) { + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]any + expectError bool + expectedErrMsg string + expectedErrMsgRegexp *regexp.Regexp + }{ + { + name: "successful workflow artifacts read", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposActionsRunsArtifactsByOwnerByRepoByRunId, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + artifacts := &github.ArtifactList{ + TotalCount: github.Ptr(int64(2)), + Artifacts: []*github.Artifact{ + { + ID: github.Ptr(int64(12345)), + Name: github.Ptr("artifact-1"), + }, + { + ID: github.Ptr(int64(12346)), + Name: github.Ptr("artifact-2"), + }, + }, + } + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(artifacts) + }), + ), + ), + requestArgs: map[string]any{ + "action": "list_workflow_artifacts", + "owner": "owner", + "repo": "repo", + "resource_id": float64(1), + }, + expectError: false, + }, + { + name: "missing workflow artifacts read", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposActionsRunsArtifactsByOwnerByRepoByRunId, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + }), + ), + ), + requestArgs: map[string]any{ + "action": "list_workflow_artifacts", + "owner": "owner", + "repo": "repo", + "resource_id": float64(99999), + }, + expectError: true, + expectedErrMsgRegexp: regexp.MustCompile(`^failed to list workflow run artifacts: GET .*/repos/owner/repo/actions/runs/99999/artifacts.* 404.*$`), + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Setup client with mock + client := github.NewClient(tc.mockedClient) + _, handler := ActionsRead(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.Contains(t, tc.expectedErrMsg, textContent.Text) + return + } + + if tc.expectedErrMsgRegexp != nil { + assert.Regexp(t, tc.expectedErrMsgRegexp, textContent.Text) + return + } + }) + } +} + +func Test_ActionsResourceRead_GetWorkflowUsage(t *testing.T) { + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]any + expectError bool + expectedErrMsg string + expectedErrMsgRegexp *regexp.Regexp + }{ + { + name: "successful workflow usage read", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposActionsRunsTimingByOwnerByRepoByRunId, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + workflowUsage := &github.WorkflowRunUsage{ + Billable: &github.WorkflowRunBillMap{ + "UBUNTU": &github.WorkflowRunBill{ + TotalMS: github.Ptr(int64(60000)), + Jobs: github.Ptr(1), + JobRuns: []*github.WorkflowRunJobRun{ + &github.WorkflowRunJobRun{ + JobID: github.Ptr(1), + DurationMS: github.Ptr(int64(600)), + }, + }, + }, + }, + RunDurationMS: github.Ptr(int64(60000)), + } + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(workflowUsage) + }), + ), + ), + requestArgs: map[string]any{ + "action": "get_workflow_run_usage", + "owner": "owner", + "repo": "repo", + "resource_id": float64(1), + }, + expectError: false, + }, + { + name: "missing workflow usage read", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposActionsRunsTimingByOwnerByRepoByRunId, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + }), + ), + ), + requestArgs: map[string]any{ + "action": "get_workflow_run_usage", + "owner": "owner", + "repo": "repo", + "resource_id": float64(99999), + }, + expectError: true, + expectedErrMsgRegexp: regexp.MustCompile(`^failed to get workflow run usage: GET .*/repos/owner/repo/actions/runs/99999/timing.* 404.*$`), + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Setup client with mock + client := github.NewClient(tc.mockedClient) + _, handler := ActionsRead(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.Contains(t, tc.expectedErrMsg, textContent.Text) + return + } + + if tc.expectedErrMsgRegexp != nil { + assert.Regexp(t, tc.expectedErrMsgRegexp, textContent.Text) + return + } + }) + } +} From ee9abaa001fbe7ce19a1824e7acb7f179e85cbee Mon Sep 17 00:00:00 2001 From: Adam Holt Date: Mon, 27 Oct 2025 17:23:42 +0100 Subject: [PATCH 09/22] Change to actions_read --- pkg/github/actions.go | 2 +- pkg/github/actions_test.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/github/actions.go b/pkg/github/actions.go index d276d5a52..4523b8cbd 100644 --- a/pkg/github/actions.go +++ b/pkg/github/actions.go @@ -65,7 +65,7 @@ func ActionFromString(s string) actionsActionType { } func ActionsRead(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { - return mcp.NewTool("actions_resource_read", + return mcp.NewTool("actions_read", mcp.WithDescription(t("TOOL_ACTIONS_READ_DESCRIPTION", "Tools for reading GitHub Actions resources")), mcp.WithToolAnnotation(mcp.ToolAnnotation{ Title: t("TOOL_ACTIONS_READ_USER_TITLE", "Read GitHub Actions"), diff --git a/pkg/github/actions_test.go b/pkg/github/actions_test.go index 8add2a8d6..fc8f14999 100644 --- a/pkg/github/actions_test.go +++ b/pkg/github/actions_test.go @@ -1286,7 +1286,7 @@ func Test_ActionsResourceRead(t *testing.T) { mockClient := github.NewClient(nil) tool, _ := ActionsRead(stubGetClientFn(mockClient), translations.NullTranslationHelper) - assert.Equal(t, "actions_resource_read", tool.Name) + assert.Equal(t, "actions_read", tool.Name) assert.NotEmpty(t, tool.Description) assert.Contains(t, tool.InputSchema.Properties, "action") assert.Contains(t, tool.InputSchema.Properties, "owner") From 6baf170bb0c3ae04028ed91c93b3506efed55c6e Mon Sep 17 00:00:00 2001 From: Adam Holt Date: Tue, 28 Oct 2025 14:48:46 +0100 Subject: [PATCH 10/22] Take a string and parse --- pkg/github/actions.go | 60 +++++++++++++++++++++++++++++--------- pkg/github/actions_test.go | 48 +++++++++++++++--------------- 2 files changed, 70 insertions(+), 38 deletions(-) diff --git a/pkg/github/actions.go b/pkg/github/actions.go index 4523b8cbd..7099122aa 100644 --- a/pkg/github/actions.go +++ b/pkg/github/actions.go @@ -188,7 +188,7 @@ func ActionsRead(getClient GetClientFn, t translations.TranslationHelperFunc) (t return mcp.NewToolResultError(fmt.Sprintf("unknown action: %s", actionTypeStr)), nil } - resourceIDInt, err := RequiredInt(request, "resource_id") + resourceID, err := RequiredParam[string](request, "resource_id") if err != nil { return mcp.NewToolResultError(err.Error()), nil } @@ -203,23 +203,36 @@ func ActionsRead(getClient GetClientFn, t translations.TranslationHelperFunc) (t return nil, fmt.Errorf("failed to get GitHub client: %w", err) } + var resourceIDInt int64 + var parseErr error + switch resourceType { + case actionsActionTypeGetWorkflow, actionsActionTypeListWorkflowRuns: + // Do nothing, we accept both a string workflow ID or filename + default: + // For other actions, resource ID must be an integer + resourceIDInt, parseErr = strconv.ParseInt(resourceID, 10, 64) + if parseErr != nil { + return mcp.NewToolResultError(fmt.Sprintf("invalid resource_id, must be an integer for action %s: %v", actionTypeStr, parseErr)), nil + } + } + switch resourceType { case actionsActionTypeGetWorkflow: - return getWorkflow(ctx, client, request, owner, repo, int64(resourceIDInt)) + return getWorkflow(ctx, client, request, owner, repo, resourceID) case actionsActionTypeGetWorkflowRun: - return getWorkflowRun(ctx, client, request, owner, repo, int64(resourceIDInt)) + return getWorkflowRun(ctx, client, request, owner, repo, resourceIDInt) case actionsActionTypeListWorkflowRuns: - return listWorkflowRuns(ctx, client, request, owner, repo, int64(resourceIDInt), pagination) + return listWorkflowRuns(ctx, client, request, owner, repo, resourceID, pagination) case actionsActionTypeGetWorkflowJob: - return getWorkflowJob(ctx, client, request, owner, repo, int64(resourceIDInt)) + return getWorkflowJob(ctx, client, request, owner, repo, resourceIDInt) case actionsActionTypeListWorkflowJobs: - return listWorkflowJobs(ctx, client, request, owner, repo, int64(resourceIDInt), pagination) + return listWorkflowJobs(ctx, client, request, owner, repo, resourceIDInt, pagination) case actionsActionTypeDownloadWorkflowArtifact: - return downloadWorkflowArtifact(ctx, client, request, owner, repo, int64(resourceIDInt)) + return downloadWorkflowArtifact(ctx, client, request, owner, repo, resourceIDInt) case actionsActionTypeListWorkflowArtifacts: - return listWorkflowArtifacts(ctx, client, request, owner, repo, int64(resourceIDInt), pagination) + return listWorkflowArtifacts(ctx, client, request, owner, repo, resourceIDInt, pagination) case actionsActionTypeGetWorkflowRunUsage: - return getWorkflowRunUsage(ctx, client, request, owner, repo, int64(resourceIDInt)) + return getWorkflowRunUsage(ctx, client, request, owner, repo, resourceIDInt) case actionsActionTypeUnknown: return mcp.NewToolResultError(fmt.Sprintf("unknown action: %s", actionTypeStr)), nil default: @@ -229,8 +242,17 @@ func ActionsRead(getClient GetClientFn, t translations.TranslationHelperFunc) (t } } -func getWorkflow(ctx context.Context, client *github.Client, _ mcp.CallToolRequest, owner, repo string, resourceID int64) (*mcp.CallToolResult, error) { - workflow, resp, err := client.Actions.GetWorkflowByID(ctx, owner, repo, resourceID) +func getWorkflow(ctx context.Context, client *github.Client, _ mcp.CallToolRequest, owner, repo string, resourceID string) (*mcp.CallToolResult, error) { + var workflow *github.Workflow + var resp *github.Response + var err error + + if workflowIDInt, parseErr := strconv.ParseInt(resourceID, 10, 64); parseErr == nil { + workflow, resp, err = client.Actions.GetWorkflowByID(ctx, owner, repo, workflowIDInt) + } else { + workflow, resp, err = client.Actions.GetWorkflowByFileName(ctx, owner, repo, resourceID) + } + if err != nil { return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to get workflow", resp, err), nil } @@ -257,7 +279,7 @@ func getWorkflowRun(ctx context.Context, client *github.Client, _ mcp.CallToolRe return mcp.NewToolResultText(string(r)), nil } -func listWorkflowRuns(ctx context.Context, client *github.Client, request mcp.CallToolRequest, owner, repo string, resourceID int64, pagination PaginationParams) (*mcp.CallToolResult, error) { +func listWorkflowRuns(ctx context.Context, client *github.Client, request mcp.CallToolRequest, owner, repo string, resourceID string, pagination PaginationParams) (*mcp.CallToolResult, error) { filterArgs, err := OptionalParam[map[string]any](request, "workflow_runs_filter") if err != nil { return mcp.NewToolResultError(err.Error()), nil @@ -272,7 +294,7 @@ func listWorkflowRuns(ctx context.Context, client *github.Client, request mcp.Ca } } - workflowRuns, resp, err := client.Actions.ListWorkflowRunsByID(ctx, owner, repo, resourceID, &github.ListWorkflowRunsOptions{ + listWorkflowRunsOptions := &github.ListWorkflowRunsOptions{ Actor: filterArgsTyped["actor"], Branch: filterArgsTyped["branch"], Event: filterArgsTyped["event"], @@ -281,7 +303,17 @@ func listWorkflowRuns(ctx context.Context, client *github.Client, request mcp.Ca Page: pagination.Page, PerPage: pagination.PerPage, }, - }) + } + + var workflowRuns *github.WorkflowRuns + var resp *github.Response + + if workflowIDInt, parseErr := strconv.ParseInt(resourceID, 10, 64); parseErr == nil { + workflowRuns, resp, err = client.Actions.ListWorkflowRunsByID(ctx, owner, repo, workflowIDInt, listWorkflowRunsOptions) + } else { + workflowRuns, resp, err = client.Actions.ListWorkflowRunsByFileName(ctx, owner, repo, resourceID, listWorkflowRunsOptions) + } + if err != nil { return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to list workflow runs", resp, err), nil } diff --git a/pkg/github/actions_test.go b/pkg/github/actions_test.go index fc8f14999..0c5e2297d 100644 --- a/pkg/github/actions_test.go +++ b/pkg/github/actions_test.go @@ -480,7 +480,7 @@ func Test_ListWorkflowRunArtifacts(t *testing.T) { "action": "list_workflow_artifacts", "owner": "owner", "repo": "repo", - "resource_id": float64(12345), + "resource_id": "12345", }, expectError: false, }, @@ -558,7 +558,7 @@ func Test_DownloadWorkflowRunArtifact(t *testing.T) { "action": "download_workflow_artifact", "owner": "owner", "repo": "repo", - "resource_id": float64(123), + "resource_id": "123", }, expectError: false, }, @@ -722,7 +722,7 @@ func Test_GetWorkflowRunUsage(t *testing.T) { "action": "get_workflow_run_usage", "owner": "owner", "repo": "repo", - "resource_id": float64(12345), + "resource_id": "12345", }, expectError: false, }, @@ -1307,7 +1307,7 @@ func Test_ActionsResourceRead(t *testing.T) { requestArgs: map[string]any{ "owner": "owner", "repo": "repo", - "resource_id": float64(123), + "resource_id": "123", }, expectError: true, expectedErrMsg: "missing required parameter: action", @@ -1318,7 +1318,7 @@ func Test_ActionsResourceRead(t *testing.T) { requestArgs: map[string]any{ "action": "get_workflow", "repo": "repo", - "resource_id": float64(123), + "resource_id": "123", }, expectError: true, expectedErrMsg: "missing required parameter: owner", @@ -1329,7 +1329,7 @@ func Test_ActionsResourceRead(t *testing.T) { requestArgs: map[string]any{ "action": "get_workflow", "owner": "owner", - "resource_id": float64(123), + "resource_id": "123", }, expectError: true, expectedErrMsg: "missing required parameter: repo", @@ -1352,7 +1352,7 @@ func Test_ActionsResourceRead(t *testing.T) { "action": "random", "owner": "owner", "repo": "repo", - "resource_id": float64(123), + "resource_id": "123", }, expectError: true, expectedErrMsg: "unknown action: random", @@ -1416,7 +1416,7 @@ func Test_ActionsResourceRead_GetWorkflow(t *testing.T) { "action": "get_workflow", "owner": "owner", "repo": "repo", - "resource_id": float64(1), + "resource_id": "1", }, expectError: false, }, @@ -1434,7 +1434,7 @@ func Test_ActionsResourceRead_GetWorkflow(t *testing.T) { "action": "get_workflow", "owner": "owner", "repo": "repo", - "resource_id": float64(2), + "resource_id": "2", }, expectError: true, expectedErrMsgRegexp: regexp.MustCompile(`^failed to get workflow: GET .*/repos/owner/repo/actions/workflows/2: 404.*$`), @@ -1511,7 +1511,7 @@ func Test_ActionsResourceRead_GetWorkflowRun(t *testing.T) { "action": "get_workflow_run", "owner": "owner", "repo": "repo", - "resource_id": float64(12345), + "resource_id": "12345", }, expectError: false, }, @@ -1529,7 +1529,7 @@ func Test_ActionsResourceRead_GetWorkflowRun(t *testing.T) { "action": "get_workflow_run", "owner": "owner", "repo": "repo", - "resource_id": float64(99999), + "resource_id": "99999", }, expectError: true, expectedErrMsgRegexp: regexp.MustCompile(`^failed to get workflow run: GET .*/repos/owner/repo/actions/runs/99999: 404.*$`), @@ -1620,7 +1620,7 @@ func Test_ActionsResourceRead_ListWorkflowRuns(t *testing.T) { "action": "list_workflow_runs", "owner": "owner", "repo": "repo", - "resource_id": float64(1), + "resource_id": "1", }, expectError: false, }, @@ -1659,7 +1659,7 @@ func Test_ActionsResourceRead_ListWorkflowRuns(t *testing.T) { "action": "list_workflow_runs", "owner": "owner", "repo": "repo", - "resource_id": float64(1), + "resource_id": "1", "workflow_runs_filter": map[string]any{ "actor": "omgitsads", "status": "completed", @@ -1681,7 +1681,7 @@ func Test_ActionsResourceRead_ListWorkflowRuns(t *testing.T) { "action": "list_workflow_runs", "owner": "owner", "repo": "repo", - "resource_id": float64(99999), + "resource_id": "99999", }, expectError: true, expectedErrMsgRegexp: regexp.MustCompile(`^failed to list workflow runs: GET .*/repos/owner/repo/actions/workflows/99999/runs.* 404.*$`), @@ -1749,7 +1749,7 @@ func Test_ActionsResourceRead_GetWorkflowJob(t *testing.T) { "action": "get_workflow_job", "owner": "owner", "repo": "repo", - "resource_id": float64(12345), + "resource_id": "12345", }, expectError: false, }, @@ -1767,7 +1767,7 @@ func Test_ActionsResourceRead_GetWorkflowJob(t *testing.T) { "action": "get_workflow_job", "owner": "owner", "repo": "repo", - "resource_id": float64(99999), + "resource_id": "99999", }, expectError: true, expectedErrMsgRegexp: regexp.MustCompile(`^failed to get workflow job: GET .*/repos/owner/repo/actions/jobs/99999: 404.*$`), @@ -1846,7 +1846,7 @@ func Test_ActionsResourceRead_ListWorkflowJobs(t *testing.T) { "action": "list_workflow_jobs", "owner": "owner", "repo": "repo", - "resource_id": float64(1), + "resource_id": "1", }, expectError: false, }, @@ -1864,7 +1864,7 @@ func Test_ActionsResourceRead_ListWorkflowJobs(t *testing.T) { "action": "list_workflow_jobs", "owner": "owner", "repo": "repo", - "resource_id": float64(99999), + "resource_id": "99999", }, expectError: true, expectedErrMsgRegexp: regexp.MustCompile(`^failed to list workflow jobs: GET .*/repos/owner/repo/actions/runs/99999/jobs.* 404.*$`), @@ -1926,7 +1926,7 @@ func Test_ActionsResourceRead_DownloadWorkflowArtifact(t *testing.T) { "action": "download_workflow_artifact", "owner": "owner", "repo": "repo", - "resource_id": float64(12345), + "resource_id": "12345", }, expectError: false, }, @@ -1944,7 +1944,7 @@ func Test_ActionsResourceRead_DownloadWorkflowArtifact(t *testing.T) { "action": "download_workflow_artifact", "owner": "owner", "repo": "repo", - "resource_id": float64(99999), + "resource_id": "99999", }, expectError: true, expectedErrMsg: "failed to get artifact download URL: unexpected status code: 404 Not Found", @@ -2019,7 +2019,7 @@ func Test_ActionsResourceRead_ListWorkflowArtifacts(t *testing.T) { "action": "list_workflow_artifacts", "owner": "owner", "repo": "repo", - "resource_id": float64(1), + "resource_id": "1", }, expectError: false, }, @@ -2037,7 +2037,7 @@ func Test_ActionsResourceRead_ListWorkflowArtifacts(t *testing.T) { "action": "list_workflow_artifacts", "owner": "owner", "repo": "repo", - "resource_id": float64(99999), + "resource_id": "99999", }, expectError: true, expectedErrMsgRegexp: regexp.MustCompile(`^failed to list workflow run artifacts: GET .*/repos/owner/repo/actions/runs/99999/artifacts.* 404.*$`), @@ -2114,7 +2114,7 @@ func Test_ActionsResourceRead_GetWorkflowUsage(t *testing.T) { "action": "get_workflow_run_usage", "owner": "owner", "repo": "repo", - "resource_id": float64(1), + "resource_id": "1", }, expectError: false, }, @@ -2132,7 +2132,7 @@ func Test_ActionsResourceRead_GetWorkflowUsage(t *testing.T) { "action": "get_workflow_run_usage", "owner": "owner", "repo": "repo", - "resource_id": float64(99999), + "resource_id": "99999", }, expectError: true, expectedErrMsgRegexp: regexp.MustCompile(`^failed to get workflow run usage: GET .*/repos/owner/repo/actions/runs/99999/timing.* 404.*$`), From 88869ceae07a71667a127b8084c57e3554bfa1c4 Mon Sep 17 00:00:00 2001 From: Adam Holt Date: Wed, 29 Oct 2025 16:45:39 +0100 Subject: [PATCH 11/22] Update description --- pkg/github/actions.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/github/actions.go b/pkg/github/actions.go index 7099122aa..0c21c95d1 100644 --- a/pkg/github/actions.go +++ b/pkg/github/actions.go @@ -66,9 +66,9 @@ func ActionFromString(s string) actionsActionType { func ActionsRead(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { return mcp.NewTool("actions_read", - mcp.WithDescription(t("TOOL_ACTIONS_READ_DESCRIPTION", "Tools for reading GitHub Actions resources")), + mcp.WithDescription(t("TOOL_ACTIONS_READ_DESCRIPTION", "Tools for reading GitHub Actions resources (workflows, workflow runs, jobs, and artifacts)")), mcp.WithToolAnnotation(mcp.ToolAnnotation{ - Title: t("TOOL_ACTIONS_READ_USER_TITLE", "Read GitHub Actions"), + Title: t("TOOL_ACTIONS_READ_USER_TITLE", "Get details of GitHub Actions resources (workflows, workflow runs, jobs, and artifacts)"), ReadOnlyHint: ToBoolPtr(true), }), mcp.WithString("action", From 972edb15fa32f60d67f1a4ae184e688cc0214336 Mon Sep 17 00:00:00 2001 From: Adam Holt Date: Thu, 30 Oct 2025 11:29:57 +0100 Subject: [PATCH 12/22] Update description --- pkg/github/actions.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/github/actions.go b/pkg/github/actions.go index 0c21c95d1..029456935 100644 --- a/pkg/github/actions.go +++ b/pkg/github/actions.go @@ -66,7 +66,7 @@ func ActionFromString(s string) actionsActionType { func ActionsRead(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { return mcp.NewTool("actions_read", - mcp.WithDescription(t("TOOL_ACTIONS_READ_DESCRIPTION", "Tools for reading GitHub Actions resources (workflows, workflow runs, jobs, and artifacts)")), + mcp.WithDescription(t("TOOL_ACTIONS_READ_DESCRIPTION", "Tools for reading GitHub Actions resources. Use this tool to get details about individual Actions Workflows, list and get individual Actions Workflow Runs, Jobs, and Artifacts.")), mcp.WithToolAnnotation(mcp.ToolAnnotation{ Title: t("TOOL_ACTIONS_READ_USER_TITLE", "Get details of GitHub Actions resources (workflows, workflow runs, jobs, and artifacts)"), ReadOnlyHint: ToBoolPtr(true), From cdcbe4bff22e69ab7ced3580aed281b3d12ef2e8 Mon Sep 17 00:00:00 2001 From: Adam Holt Date: Thu, 30 Oct 2025 12:46:01 +0100 Subject: [PATCH 13/22] Add filename for the description --- pkg/github/actions.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/github/actions.go b/pkg/github/actions.go index 029456935..eafd5995c 100644 --- a/pkg/github/actions.go +++ b/pkg/github/actions.go @@ -96,7 +96,7 @@ func ActionsRead(getClient GetClientFn, t translations.TranslationHelperFunc) (t mcp.WithNumber("resource_id", mcp.Required(), mcp.Description(`The unique identifier of the resource. This will vary based on the "action" provided, so ensure you provide the correct ID: -- Provide a workflow ID for 'get_workflow' and 'list_workflow_runs' actions. +- Provide a workflow ID or Filename for for 'get_workflow' and 'list_workflow_runs' actions. - Provide a workflow run ID for 'get_workflow_run', 'list_workflow_jobs', 'download_workflow_artifact', 'list_workflow_artifacts' and 'get_workflow_run_usage' actions. - Provide a job ID for the 'get_workflow_job' action. `), From 73131911f29d2677824d5eb2d17e7bb60e28c3bf Mon Sep 17 00:00:00 2001 From: Adam Holt Date: Thu, 30 Oct 2025 12:52:05 +0100 Subject: [PATCH 14/22] Add the list_workflows option to the actions_read tool --- pkg/github/actions.go | 86 +++++++++++++------------------------------ 1 file changed, 26 insertions(+), 60 deletions(-) diff --git a/pkg/github/actions.go b/pkg/github/actions.go index eafd5995c..8db76420a 100644 --- a/pkg/github/actions.go +++ b/pkg/github/actions.go @@ -26,6 +26,7 @@ type actionsActionType int const ( actionsActionTypeUnknown actionsActionType = iota + actionsActionTypeListWorkflows actionsActionTypeGetWorkflow actionsActionTypeGetWorkflowRun actionsActionTypeListWorkflowRuns @@ -37,13 +38,14 @@ const ( ) var actionsResourceTypes = map[actionsActionType]string{ + actionsActionTypeListWorkflows: "list_workflows", actionsActionTypeGetWorkflow: "get_workflow", actionsActionTypeGetWorkflowRun: "get_workflow_run", actionsActionTypeListWorkflowRuns: "list_workflow_runs", actionsActionTypeGetWorkflowJob: "get_workflow_job", actionsActionTypeListWorkflowJobs: "list_workflow_jobs", - actionsActionTypeDownloadWorkflowArtifact: "download_workflow_artifact", - actionsActionTypeListWorkflowArtifacts: "list_workflow_artifacts", + actionsActionTypeDownloadWorkflowArtifact: "download_workflow_run_artifact", + actionsActionTypeListWorkflowArtifacts: "list_workflow_run_artifacts", actionsActionTypeGetWorkflowRunUsage: "get_workflow_run_usage", } @@ -94,10 +96,10 @@ func ActionsRead(getClient GetClientFn, t translations.TranslationHelperFunc) (t mcp.Description(DescriptionRepositoryName), ), mcp.WithNumber("resource_id", - mcp.Required(), mcp.Description(`The unique identifier of the resource. This will vary based on the "action" provided, so ensure you provide the correct ID: -- Provide a workflow ID or Filename for for 'get_workflow' and 'list_workflow_runs' actions. -- Provide a workflow run ID for 'get_workflow_run', 'list_workflow_jobs', 'download_workflow_artifact', 'list_workflow_artifacts' and 'get_workflow_run_usage' actions. +- Do not provide any resource ID for 'list_workflows' action. +- Provide a workflow ID or Filename for 'get_workflow' and 'list_workflow_runs' actions. +- Provide a workflow run ID for 'get_workflow_run', 'list_workflow_jobs', 'download_workflow_run_artifact', 'list_workflow_run_artifacts' and 'get_workflow_run_usage' actions. - Provide a job ID for the 'get_workflow_job' action. `), ), @@ -188,7 +190,7 @@ func ActionsRead(getClient GetClientFn, t translations.TranslationHelperFunc) (t return mcp.NewToolResultError(fmt.Sprintf("unknown action: %s", actionTypeStr)), nil } - resourceID, err := RequiredParam[string](request, "resource_id") + resourceID, err := OptionalParam[string](request, "resource_id") if err != nil { return mcp.NewToolResultError(err.Error()), nil } @@ -217,6 +219,8 @@ func ActionsRead(getClient GetClientFn, t translations.TranslationHelperFunc) (t } switch resourceType { + case actionsActionTypeListWorkflows: + return listWorkflows(ctx, client, request, owner, repo, pagination) case actionsActionTypeGetWorkflow: return getWorkflow(ctx, client, request, owner, repo, resourceID) case actionsActionTypeGetWorkflowRun: @@ -427,63 +431,25 @@ func listWorkflowArtifacts(ctx context.Context, client *github.Client, _ mcp.Cal } // ListWorkflows creates a tool to list workflows in a repository -func ListWorkflows(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { - return mcp.NewTool("list_workflows", - mcp.WithDescription(t("TOOL_LIST_WORKFLOWS_DESCRIPTION", "List workflows in a repository")), - mcp.WithToolAnnotation(mcp.ToolAnnotation{ - Title: t("TOOL_LIST_WORKFLOWS_USER_TITLE", "List workflows"), - ReadOnlyHint: ToBoolPtr(true), - }), - mcp.WithString("owner", - mcp.Required(), - mcp.Description(DescriptionRepositoryOwner), - ), - mcp.WithString("repo", - mcp.Required(), - mcp.Description(DescriptionRepositoryName), - ), - WithPagination(), - ), - 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 - } - - // Get optional pagination parameters - pagination, err := OptionalPaginationParams(request) - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - - client, err := getClient(ctx) - if err != nil { - return nil, fmt.Errorf("failed to get GitHub client: %w", err) - } - - // Set up list options - opts := &github.ListOptions{ - PerPage: pagination.PerPage, - Page: pagination.Page, - } +func listWorkflows(ctx context.Context, client *github.Client, _ mcp.CallToolRequest, owner, repo string, pagination PaginationParams) (*mcp.CallToolResult, error) { + // Set up list options + opts := &github.ListOptions{ + PerPage: pagination.PerPage, + Page: pagination.Page, + } - workflows, resp, err := client.Actions.ListWorkflows(ctx, owner, repo, opts) - if err != nil { - return nil, fmt.Errorf("failed to list workflows: %w", err) - } - defer func() { _ = resp.Body.Close() }() + workflows, resp, err := client.Actions.ListWorkflows(ctx, owner, repo, opts) + if err != nil { + return nil, fmt.Errorf("failed to list workflows: %w", err) + } + defer func() { _ = resp.Body.Close() }() - r, err := json.Marshal(workflows) - if err != nil { - return nil, fmt.Errorf("failed to marshal response: %w", err) - } + r, err := json.Marshal(workflows) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } - return mcp.NewToolResultText(string(r)), nil - } + return mcp.NewToolResultText(string(r)), nil } // RunWorkflow creates a tool to run an Actions workflow From c33b0ba88f5343318d39e2b81bcc3bd2db2673c8 Mon Sep 17 00:00:00 2001 From: Adam Holt Date: Thu, 30 Oct 2025 13:01:00 +0100 Subject: [PATCH 15/22] comment out tool for now --- pkg/github/tools.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/github/tools.go b/pkg/github/tools.go index 377252283..988029dac 100644 --- a/pkg/github/tools.go +++ b/pkg/github/tools.go @@ -256,7 +256,7 @@ func DefaultToolsetGroup(readOnly bool, getClient GetClientFn, getGQLClient GetG actions := toolsets.NewToolset(ToolsetMetadataActions.ID, ToolsetMetadataActions.Description). AddReadTools( toolsets.NewServerTool(ActionsRead(getClient, t)), - toolsets.NewServerTool(ListWorkflows(getClient, t)), + // toolsets.NewServerTool(ListWorkflows(getClient, t)), // toolsets.NewServerTool(ListWorkflowRuns(getClient, t)), // toolsets.NewServerTool(GetWorkflowRun(getClient, t)), toolsets.NewServerTool(GetWorkflowRunLogs(getClient, t)), From a5a2267d1ac09a0ce1ad7dd92784534d21ed8893 Mon Sep 17 00:00:00 2001 From: Adam Holt Date: Thu, 30 Oct 2025 14:50:17 +0100 Subject: [PATCH 16/22] Fix tests --- pkg/github/actions.go | 6 +++ pkg/github/actions_test.go | 94 +++++++++++++++----------------------- 2 files changed, 43 insertions(+), 57 deletions(-) diff --git a/pkg/github/actions.go b/pkg/github/actions.go index 8db76420a..ff197ecfd 100644 --- a/pkg/github/actions.go +++ b/pkg/github/actions.go @@ -208,9 +208,15 @@ func ActionsRead(getClient GetClientFn, t translations.TranslationHelperFunc) (t var resourceIDInt int64 var parseErr error switch resourceType { + case actionsActionTypeListWorkflows: + // No resource ID required case actionsActionTypeGetWorkflow, actionsActionTypeListWorkflowRuns: // Do nothing, we accept both a string workflow ID or filename default: + if resourceID == "" { + return mcp.NewToolResultError(fmt.Sprintf("missing required parameter for action %s: resource_id", actionTypeStr)), nil + } + // For other actions, resource ID must be an integer resourceIDInt, parseErr = strconv.ParseInt(resourceID, 10, 64) if parseErr != nil { diff --git a/pkg/github/actions_test.go b/pkg/github/actions_test.go index 0c5e2297d..1d891df7a 100644 --- a/pkg/github/actions_test.go +++ b/pkg/github/actions_test.go @@ -3,6 +3,7 @@ package github import ( "context" "encoding/json" + "fmt" "io" "net/http" "net/http/httptest" @@ -22,19 +23,7 @@ import ( "github.com/stretchr/testify/require" ) -func Test_ListWorkflows(t *testing.T) { - // Verify tool definition once - mockClient := github.NewClient(nil) - tool, _ := ListWorkflows(stubGetClientFn(mockClient), translations.NullTranslationHelper) - - assert.Equal(t, "list_workflows", 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, "perPage") - assert.Contains(t, tool.InputSchema.Properties, "page") - assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo"}) - +func Test_ActionsRead_ListWorkflows(t *testing.T) { tests := []struct { name string mockedClient *http.Client @@ -83,8 +72,9 @@ func Test_ListWorkflows(t *testing.T) { ), ), requestArgs: map[string]any{ - "owner": "owner", - "repo": "repo", + "action": actionsActionTypeListWorkflows.String(), + "owner": "owner", + "repo": "repo", }, expectError: false, }, @@ -92,7 +82,8 @@ func Test_ListWorkflows(t *testing.T) { name: "missing required parameter owner", mockedClient: mock.NewMockedHTTPClient(), requestArgs: map[string]any{ - "repo": "repo", + "action": actionsActionTypeListWorkflows.String(), + "repo": "repo", }, expectError: true, expectedErrMsg: "missing required parameter: owner", @@ -103,7 +94,7 @@ func Test_ListWorkflows(t *testing.T) { t.Run(tc.name, func(t *testing.T) { // Setup client with mock client := github.NewClient(tc.mockedClient) - _, handler := ListWorkflows(stubGetClientFn(client), translations.NullTranslationHelper) + _, handler := ActionsRead(stubGetClientFn(client), translations.NullTranslationHelper) // Create call request request := createMCPRequest(tc.requestArgs) @@ -477,7 +468,7 @@ func Test_ListWorkflowRunArtifacts(t *testing.T) { ), ), requestArgs: map[string]any{ - "action": "list_workflow_artifacts", + "action": actionsActionTypeListWorkflowArtifacts.String(), "owner": "owner", "repo": "repo", "resource_id": "12345", @@ -488,12 +479,12 @@ func Test_ListWorkflowRunArtifacts(t *testing.T) { name: "missing required parameter resource_id", mockedClient: mock.NewMockedHTTPClient(), requestArgs: map[string]any{ - "action": "list_workflow_artifacts", + "action": actionsActionTypeListWorkflowArtifacts.String(), "owner": "owner", "repo": "repo", }, expectError: true, - expectedErrMsg: "missing required parameter: resource_id", + expectedErrMsg: fmt.Sprintf("missing required parameter for action %s: resource_id", actionsActionTypeListWorkflowArtifacts.String()), }, } @@ -555,7 +546,7 @@ func Test_DownloadWorkflowRunArtifact(t *testing.T) { ), ), requestArgs: map[string]any{ - "action": "download_workflow_artifact", + "action": actionsActionTypeDownloadWorkflowArtifact.String(), "owner": "owner", "repo": "repo", "resource_id": "123", @@ -566,12 +557,12 @@ func Test_DownloadWorkflowRunArtifact(t *testing.T) { name: "missing required parameter resource_id", mockedClient: mock.NewMockedHTTPClient(), requestArgs: map[string]any{ - "action": "download_workflow_artifact", + "action": actionsActionTypeDownloadWorkflowArtifact.String(), "owner": "owner", "repo": "repo", }, expectError: true, - expectedErrMsg: "missing required parameter: resource_id", + expectedErrMsg: fmt.Sprintf("missing required parameter for action %s: resource_id", actionsActionTypeDownloadWorkflowArtifact.String()), }, } @@ -719,7 +710,7 @@ func Test_GetWorkflowRunUsage(t *testing.T) { ), ), requestArgs: map[string]any{ - "action": "get_workflow_run_usage", + "action": actionsActionTypeGetWorkflowRunUsage.String(), "owner": "owner", "repo": "repo", "resource_id": "12345", @@ -730,12 +721,12 @@ func Test_GetWorkflowRunUsage(t *testing.T) { name: "missing required parameter run_id", mockedClient: mock.NewMockedHTTPClient(), requestArgs: map[string]any{ - "action": "get_workflow_run_usage", + "action": actionsActionTypeGetWorkflowRunUsage.String(), "owner": "owner", "repo": "repo", }, expectError: true, - expectedErrMsg: "missing required parameter: resource_id", + expectedErrMsg: fmt.Sprintf("missing required parameter for action %s: resource_id", actionsActionTypeGetWorkflowRunUsage.String()), }, } @@ -1292,7 +1283,7 @@ func Test_ActionsResourceRead(t *testing.T) { assert.Contains(t, tool.InputSchema.Properties, "owner") assert.Contains(t, tool.InputSchema.Properties, "repo") assert.Contains(t, tool.InputSchema.Properties, "resource_id") - assert.ElementsMatch(t, tool.InputSchema.Required, []string{"action", "owner", "repo", "resource_id"}) + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"action", "owner", "repo"}) tests := []struct { name string @@ -1316,7 +1307,7 @@ func Test_ActionsResourceRead(t *testing.T) { name: "missing required parameter owner", mockedClient: mock.NewMockedHTTPClient(), requestArgs: map[string]any{ - "action": "get_workflow", + "action": actionsActionTypeGetWorkflow.String(), "repo": "repo", "resource_id": "123", }, @@ -1327,24 +1318,13 @@ func Test_ActionsResourceRead(t *testing.T) { name: "missing required parameter repo", mockedClient: mock.NewMockedHTTPClient(), requestArgs: map[string]any{ - "action": "get_workflow", + "action": actionsActionTypeGetWorkflow.String(), "owner": "owner", "resource_id": "123", }, expectError: true, expectedErrMsg: "missing required parameter: repo", }, - { - name: "missing required parameter resource_id", - mockedClient: mock.NewMockedHTTPClient(), - requestArgs: map[string]any{ - "action": "get_workflow", - "owner": "owner", - "repo": "repo", - }, - expectError: true, - expectedErrMsg: "missing required parameter: resource_id", - }, { name: "unknown resource", mockedClient: mock.NewMockedHTTPClient(), @@ -1413,7 +1393,7 @@ func Test_ActionsResourceRead_GetWorkflow(t *testing.T) { ), ), requestArgs: map[string]any{ - "action": "get_workflow", + "action": actionsActionTypeGetWorkflow.String(), "owner": "owner", "repo": "repo", "resource_id": "1", @@ -1431,7 +1411,7 @@ func Test_ActionsResourceRead_GetWorkflow(t *testing.T) { ), ), requestArgs: map[string]any{ - "action": "get_workflow", + "action": actionsActionTypeGetWorkflow.String(), "owner": "owner", "repo": "repo", "resource_id": "2", @@ -1508,7 +1488,7 @@ func Test_ActionsResourceRead_GetWorkflowRun(t *testing.T) { ), ), requestArgs: map[string]any{ - "action": "get_workflow_run", + "action": actionsActionTypeGetWorkflowRun.String(), "owner": "owner", "repo": "repo", "resource_id": "12345", @@ -1526,7 +1506,7 @@ func Test_ActionsResourceRead_GetWorkflowRun(t *testing.T) { ), ), requestArgs: map[string]any{ - "action": "get_workflow_run", + "action": actionsActionTypeGetWorkflowRun.String(), "owner": "owner", "repo": "repo", "resource_id": "99999", @@ -1617,7 +1597,7 @@ func Test_ActionsResourceRead_ListWorkflowRuns(t *testing.T) { ), ), requestArgs: map[string]any{ - "action": "list_workflow_runs", + "action": actionsActionTypeListWorkflowRuns.String(), "owner": "owner", "repo": "repo", "resource_id": "1", @@ -1656,7 +1636,7 @@ func Test_ActionsResourceRead_ListWorkflowRuns(t *testing.T) { ), ), requestArgs: map[string]any{ - "action": "list_workflow_runs", + "action": actionsActionTypeListWorkflowRuns.String(), "owner": "owner", "repo": "repo", "resource_id": "1", @@ -1678,7 +1658,7 @@ func Test_ActionsResourceRead_ListWorkflowRuns(t *testing.T) { ), ), requestArgs: map[string]any{ - "action": "list_workflow_runs", + "action": actionsActionTypeListWorkflowRuns.String(), "owner": "owner", "repo": "repo", "resource_id": "99999", @@ -1746,7 +1726,7 @@ func Test_ActionsResourceRead_GetWorkflowJob(t *testing.T) { ), ), requestArgs: map[string]any{ - "action": "get_workflow_job", + "action": actionsActionTypeGetWorkflowJob.String(), "owner": "owner", "repo": "repo", "resource_id": "12345", @@ -1764,7 +1744,7 @@ func Test_ActionsResourceRead_GetWorkflowJob(t *testing.T) { ), ), requestArgs: map[string]any{ - "action": "get_workflow_job", + "action": actionsActionTypeGetWorkflowJob.String(), "owner": "owner", "repo": "repo", "resource_id": "99999", @@ -1843,7 +1823,7 @@ func Test_ActionsResourceRead_ListWorkflowJobs(t *testing.T) { ), ), requestArgs: map[string]any{ - "action": "list_workflow_jobs", + "action": actionsActionTypeListWorkflowJobs.String(), "owner": "owner", "repo": "repo", "resource_id": "1", @@ -1861,7 +1841,7 @@ func Test_ActionsResourceRead_ListWorkflowJobs(t *testing.T) { ), ), requestArgs: map[string]any{ - "action": "list_workflow_jobs", + "action": actionsActionTypeListWorkflowJobs.String(), "owner": "owner", "repo": "repo", "resource_id": "99999", @@ -1923,7 +1903,7 @@ func Test_ActionsResourceRead_DownloadWorkflowArtifact(t *testing.T) { ), ), requestArgs: map[string]any{ - "action": "download_workflow_artifact", + "action": actionsActionTypeDownloadWorkflowArtifact.String(), "owner": "owner", "repo": "repo", "resource_id": "12345", @@ -1941,7 +1921,7 @@ func Test_ActionsResourceRead_DownloadWorkflowArtifact(t *testing.T) { ), ), requestArgs: map[string]any{ - "action": "download_workflow_artifact", + "action": actionsActionTypeDownloadWorkflowArtifact.String(), "owner": "owner", "repo": "repo", "resource_id": "99999", @@ -2016,7 +1996,7 @@ func Test_ActionsResourceRead_ListWorkflowArtifacts(t *testing.T) { ), ), requestArgs: map[string]any{ - "action": "list_workflow_artifacts", + "action": actionsActionTypeListWorkflowArtifacts.String(), "owner": "owner", "repo": "repo", "resource_id": "1", @@ -2034,7 +2014,7 @@ func Test_ActionsResourceRead_ListWorkflowArtifacts(t *testing.T) { ), ), requestArgs: map[string]any{ - "action": "list_workflow_artifacts", + "action": actionsActionTypeListWorkflowArtifacts.String(), "owner": "owner", "repo": "repo", "resource_id": "99999", @@ -2111,7 +2091,7 @@ func Test_ActionsResourceRead_GetWorkflowUsage(t *testing.T) { ), ), requestArgs: map[string]any{ - "action": "get_workflow_run_usage", + "action": actionsActionTypeGetWorkflowRunUsage.String(), "owner": "owner", "repo": "repo", "resource_id": "1", @@ -2129,7 +2109,7 @@ func Test_ActionsResourceRead_GetWorkflowUsage(t *testing.T) { ), ), requestArgs: map[string]any{ - "action": "get_workflow_run_usage", + "action": actionsActionTypeGetWorkflowRunUsage.String(), "owner": "owner", "repo": "repo", "resource_id": "99999", From eb4dd2784e719b929f2aecac55ac8bdd24fbfb98 Mon Sep 17 00:00:00 2001 From: Adam Holt Date: Thu, 30 Oct 2025 14:56:54 +0100 Subject: [PATCH 17/22] fix linter issues --- pkg/github/actions.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pkg/github/actions.go b/pkg/github/actions.go index ff197ecfd..a8abf7042 100644 --- a/pkg/github/actions.go +++ b/pkg/github/actions.go @@ -57,7 +57,7 @@ func (r actionsActionType) String() string { return "unknown" } -func ActionFromString(s string) actionsActionType { +func actionFromString(s string) actionsActionType { for r, str := range actionsResourceTypes { if str == strings.ToLower(s) { return r @@ -185,7 +185,7 @@ func ActionsRead(getClient GetClientFn, t translations.TranslationHelperFunc) (t return mcp.NewToolResultError(err.Error()), nil } - resourceType := ActionFromString(actionTypeStr) + resourceType := actionFromString(actionTypeStr) if resourceType == actionsActionTypeUnknown { return mcp.NewToolResultError(fmt.Sprintf("unknown action: %s", actionTypeStr)), nil } @@ -1127,7 +1127,7 @@ func DeleteWorkflowRunLogs(getClient GetClientFn, t translations.TranslationHelp } // GetWorkflowRunUsage creates a tool to get usage metrics for a workflow run -func getWorkflowRunUsage(ctx context.Context, client *github.Client, request mcp.CallToolRequest, owner, repo string, resourceID int64) (*mcp.CallToolResult, error) { +func getWorkflowRunUsage(ctx context.Context, client *github.Client, _ mcp.CallToolRequest, owner, repo string, resourceID int64) (*mcp.CallToolResult, error) { usage, resp, err := client.Actions.GetWorkflowRunUsageByID(ctx, owner, repo, resourceID) if err != nil { return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to get workflow run usage", resp, err), nil From ff4af578ded24becf882a4d24a9578d0c61d8baa Mon Sep 17 00:00:00 2001 From: Adam Holt Date: Thu, 30 Oct 2025 16:46:17 +0100 Subject: [PATCH 18/22] More detailed descriptions --- pkg/github/actions.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/pkg/github/actions.go b/pkg/github/actions.go index 4fb056436..b1b0e0458 100644 --- a/pkg/github/actions.go +++ b/pkg/github/actions.go @@ -68,7 +68,10 @@ func actionFromString(s string) actionsActionType { func ActionsRead(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { return mcp.NewTool("actions_read", - mcp.WithDescription(t("TOOL_ACTIONS_READ_DESCRIPTION", "Tools for reading GitHub Actions resources. Use this tool to get details about individual Actions Workflows, list and get individual Actions Workflow Runs, Jobs, and Artifacts.")), + mcp.WithDescription(t("TOOL_ACTIONS_READ_DESCRIPTION", `Tools for reading GitHub Actions resources. +Use this tool to list workflows in a repository, or list workflow runs, jobs, and artifacts for a specific workflow or workflow run. +Use this tool also to get details about individual workflows, workflow runs, jobs, and artifacts, by using their unique IDs. +`)), mcp.WithToolAnnotation(mcp.ToolAnnotation{ Title: t("TOOL_ACTIONS_READ_USER_TITLE", "Get details of GitHub Actions resources (workflows, workflow runs, jobs, and artifacts)"), ReadOnlyHint: ToBoolPtr(true), From 6722490e6173fa5a7cdd6bc894d2929a4fb8c3ce Mon Sep 17 00:00:00 2001 From: Adam Holt Date: Thu, 30 Oct 2025 17:05:58 +0100 Subject: [PATCH 19/22] Make it clear you can pass a file name --- pkg/github/actions.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/github/actions.go b/pkg/github/actions.go index b1b0e0458..6b40f8d24 100644 --- a/pkg/github/actions.go +++ b/pkg/github/actions.go @@ -101,7 +101,7 @@ Use this tool also to get details about individual workflows, workflow runs, job mcp.WithNumber("resource_id", mcp.Description(`The unique identifier of the resource. This will vary based on the "action" provided, so ensure you provide the correct ID: - Do not provide any resource ID for 'list_workflows' action. -- Provide a workflow ID or Filename for 'get_workflow' and 'list_workflow_runs' actions. +- Provide a workflow ID or workflow file name (e.g. ci.yaml) for 'get_workflow' and 'list_workflow_runs' actions. - Provide a workflow run ID for 'get_workflow_run', 'list_workflow_jobs', 'download_workflow_run_artifact', 'list_workflow_run_artifacts' and 'get_workflow_run_usage' actions. - Provide a job ID for the 'get_workflow_job' action. `), From c398bc257ab9c6e42515d247b04bfda57d267787 Mon Sep 17 00:00:00 2001 From: Adam Holt Date: Fri, 31 Oct 2025 16:01:37 +0100 Subject: [PATCH 20/22] Split out into List and Get tools, add trigger --- pkg/github/actions.go | 460 ++++++++++++++++++++----------------- pkg/github/actions_test.go | 73 +++--- pkg/github/tools.go | 14 +- 3 files changed, 302 insertions(+), 245 deletions(-) diff --git a/pkg/github/actions.go b/pkg/github/actions.go index 6b40f8d24..f6ecf0834 100644 --- a/pkg/github/actions.go +++ b/pkg/github/actions.go @@ -27,26 +27,34 @@ type actionsActionType int const ( actionsActionTypeUnknown actionsActionType = iota actionsActionTypeListWorkflows - actionsActionTypeGetWorkflow - actionsActionTypeGetWorkflowRun actionsActionTypeListWorkflowRuns - actionsActionTypeGetWorkflowJob actionsActionTypeListWorkflowJobs - actionsActionTypeDownloadWorkflowArtifact actionsActionTypeListWorkflowArtifacts + actionsActionTypeGetWorkflow + actionsActionTypeGetWorkflowRun + actionsActionTypeGetWorkflowJob actionsActionTypeGetWorkflowRunUsage + actionsActionTypeDownloadWorkflowArtifact + actionsActionTypeRunWorkflow + actionsActionTypeRerunWorkflowRun + actionsActionTypeRerunFailedJobs + actionsActionTypeCancelWorkflowRun ) var actionsResourceTypes = map[actionsActionType]string{ actionsActionTypeListWorkflows: "list_workflows", - actionsActionTypeGetWorkflow: "get_workflow", - actionsActionTypeGetWorkflowRun: "get_workflow_run", actionsActionTypeListWorkflowRuns: "list_workflow_runs", - actionsActionTypeGetWorkflowJob: "get_workflow_job", actionsActionTypeListWorkflowJobs: "list_workflow_jobs", - actionsActionTypeDownloadWorkflowArtifact: "download_workflow_run_artifact", actionsActionTypeListWorkflowArtifacts: "list_workflow_run_artifacts", + actionsActionTypeGetWorkflow: "get_workflow", + actionsActionTypeGetWorkflowRun: "get_workflow_run", + actionsActionTypeGetWorkflowJob: "get_workflow_job", actionsActionTypeGetWorkflowRunUsage: "get_workflow_run_usage", + actionsActionTypeDownloadWorkflowArtifact: "download_workflow_run_artifact", + actionsActionTypeRunWorkflow: "run_workflow", + actionsActionTypeRerunWorkflowRun: "rerun_workflow_run", + actionsActionTypeRerunFailedJobs: "rerun_failed_jobs", + actionsActionTypeCancelWorkflowRun: "cancel_workflow_run", } func (r actionsActionType) String() string { @@ -66,28 +74,21 @@ func actionFromString(s string) actionsActionType { return actionsActionTypeUnknown } -func ActionsRead(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { - return mcp.NewTool("actions_read", - mcp.WithDescription(t("TOOL_ACTIONS_READ_DESCRIPTION", `Tools for reading GitHub Actions resources. -Use this tool to list workflows in a repository, or list workflow runs, jobs, and artifacts for a specific workflow or workflow run. -Use this tool also to get details about individual workflows, workflow runs, jobs, and artifacts, by using their unique IDs. -`)), +func ActionsList(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("actions_list", + mcp.WithDescription(t("TOOL_ACTIONS_LIST_DESCRIPTION", "List GitHub Actions workflows in a repository.")), mcp.WithToolAnnotation(mcp.ToolAnnotation{ - Title: t("TOOL_ACTIONS_READ_USER_TITLE", "Get details of GitHub Actions resources (workflows, workflow runs, jobs, and artifacts)"), + Title: t("TOOL_ACTIONS_LIST_USER_TITLE", "List GitHub Actions workflows in a repository"), ReadOnlyHint: ToBoolPtr(true), }), mcp.WithString("action", mcp.Required(), mcp.Description("The action to perform"), mcp.Enum( - actionsActionTypeGetWorkflow.String(), - actionsActionTypeGetWorkflowRun.String(), + actionsActionTypeListWorkflows.String(), actionsActionTypeListWorkflowRuns.String(), - actionsActionTypeGetWorkflowJob.String(), actionsActionTypeListWorkflowJobs.String(), - actionsActionTypeDownloadWorkflowArtifact.String(), actionsActionTypeListWorkflowArtifacts.String(), - actionsActionTypeGetWorkflowRunUsage.String(), ), ), mcp.WithString("owner", @@ -98,12 +99,11 @@ Use this tool also to get details about individual workflows, workflow runs, job mcp.Required(), mcp.Description(DescriptionRepositoryName), ), - mcp.WithNumber("resource_id", + mcp.WithString("resource_id", mcp.Description(`The unique identifier of the resource. This will vary based on the "action" provided, so ensure you provide the correct ID: - Do not provide any resource ID for 'list_workflows' action. -- Provide a workflow ID or workflow file name (e.g. ci.yaml) for 'get_workflow' and 'list_workflow_runs' actions. -- Provide a workflow run ID for 'get_workflow_run', 'list_workflow_jobs', 'download_workflow_run_artifact', 'list_workflow_run_artifacts' and 'get_workflow_run_usage' actions. -- Provide a job ID for the 'get_workflow_job' action. +- Provide a workflow ID or workflow file name (e.g. ci.yaml) for 'list_workflow_runs' actions. +- Provide a workflow run ID for 'list_workflow_jobs' and 'list_workflow_run_artifacts' actions. `), ), mcp.WithObject("workflow_runs_filter", @@ -179,10 +179,12 @@ Use this tool also to get details about individual workflows, workflow runs, job if err != nil { return mcp.NewToolResultError(err.Error()), nil } + repo, err := RequiredParam[string](request, "repo") if err != nil { return mcp.NewToolResultError(err.Error()), nil } + actionTypeStr, err := RequiredParam[string](request, "action") if err != nil { return mcp.NewToolResultError(err.Error()), nil @@ -212,9 +214,7 @@ Use this tool also to get details about individual workflows, workflow runs, job var parseErr error switch resourceType { case actionsActionTypeListWorkflows: - // No resource ID required - case actionsActionTypeGetWorkflow, actionsActionTypeListWorkflowRuns: - // Do nothing, we accept both a string workflow ID or filename + // Do nothing, no resource ID needed default: if resourceID == "" { return mcp.NewToolResultError(fmt.Sprintf("missing required parameter for action %s: resource_id", actionTypeStr)), nil @@ -230,20 +230,114 @@ Use this tool also to get details about individual workflows, workflow runs, job switch resourceType { case actionsActionTypeListWorkflows: return listWorkflows(ctx, client, request, owner, repo, pagination) + case actionsActionTypeListWorkflowRuns: + return listWorkflowRuns(ctx, client, request, owner, repo, resourceID, pagination) + case actionsActionTypeListWorkflowJobs: + return listWorkflowJobs(ctx, client, request, owner, repo, resourceIDInt, pagination) + case actionsActionTypeListWorkflowArtifacts: + return listWorkflowArtifacts(ctx, client, request, owner, repo, resourceIDInt, pagination) + case actionsActionTypeUnknown: + return mcp.NewToolResultError(fmt.Sprintf("unknown action: %s", actionTypeStr)), nil + default: + // Should not reach here + return mcp.NewToolResultError(fmt.Sprintf("unknown action: %s", actionTypeStr)), nil + } + } +} + +func ActionsGet(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("actions_get", + mcp.WithDescription(t("TOOL_ACTIONS_READ_DESCRIPTION", `Tools for reading GitHub Actions resources. +Use this tool to list workflows in a repository, or list workflow runs, jobs, and artifacts for a specific workflow or workflow run. +Use this tool also to get details about individual workflows, workflow runs, jobs, and artifacts, by using their unique IDs. +`)), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_ACTIONS_READ_USER_TITLE", "Get details of GitHub Actions resources (workflows, workflow runs, jobs, and artifacts)"), + ReadOnlyHint: ToBoolPtr(true), + }), + mcp.WithString("action", + mcp.Required(), + mcp.Description("The action to perform"), + mcp.Enum( + actionsActionTypeGetWorkflow.String(), + actionsActionTypeGetWorkflowRun.String(), + actionsActionTypeGetWorkflowJob.String(), + actionsActionTypeDownloadWorkflowArtifact.String(), + actionsActionTypeGetWorkflowRunUsage.String(), + ), + ), + mcp.WithString("owner", + mcp.Required(), + mcp.Description(DescriptionRepositoryOwner), + ), + mcp.WithString("repo", + mcp.Required(), + mcp.Description(DescriptionRepositoryName), + ), + mcp.WithString("resource_id", + mcp.Required(), + mcp.Description(`The unique identifier of the resource. This will vary based on the "action" provided, so ensure you provide the correct ID: +- Provide a workflow ID or workflow file name (e.g. ci.yaml) for 'get_workflow' action. +- Provide a workflow run ID for 'get_workflow_run', 'download_workflow_run_artifact' and 'get_workflow_run_usage' actions. +- Provide a job ID for the 'get_workflow_job' action. +`), + ), + ), + 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 + } + actionTypeStr, err := RequiredParam[string](request, "action") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + resourceType := actionFromString(actionTypeStr) + if resourceType == actionsActionTypeUnknown { + return mcp.NewToolResultError(fmt.Sprintf("unknown action: %s", actionTypeStr)), nil + } + + resourceID, err := RequiredParam[string](request, "resource_id") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + client, err := getClient(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + + var resourceIDInt int64 + var parseErr error + switch resourceType { + case actionsActionTypeGetWorkflow: + // Do nothing, we accept both a string workflow ID or filename + default: + if resourceID == "" { + return mcp.NewToolResultError(fmt.Sprintf("missing required parameter for action %s: resource_id", actionTypeStr)), nil + } + + // For other actions, resource ID must be an integer + resourceIDInt, parseErr = strconv.ParseInt(resourceID, 10, 64) + if parseErr != nil { + return mcp.NewToolResultError(fmt.Sprintf("invalid resource_id, must be an integer for action %s: %v", actionTypeStr, parseErr)), nil + } + } + + switch resourceType { case actionsActionTypeGetWorkflow: return getWorkflow(ctx, client, request, owner, repo, resourceID) case actionsActionTypeGetWorkflowRun: return getWorkflowRun(ctx, client, request, owner, repo, resourceIDInt) - case actionsActionTypeListWorkflowRuns: - return listWorkflowRuns(ctx, client, request, owner, repo, resourceID, pagination) case actionsActionTypeGetWorkflowJob: return getWorkflowJob(ctx, client, request, owner, repo, resourceIDInt) - case actionsActionTypeListWorkflowJobs: - return listWorkflowJobs(ctx, client, request, owner, repo, resourceIDInt, pagination) case actionsActionTypeDownloadWorkflowArtifact: return downloadWorkflowArtifact(ctx, client, request, owner, repo, resourceIDInt) - case actionsActionTypeListWorkflowArtifacts: - return listWorkflowArtifacts(ctx, client, request, owner, repo, resourceIDInt, pagination) case actionsActionTypeGetWorkflowRunUsage: return getWorkflowRunUsage(ctx, client, request, owner, repo, resourceIDInt) case actionsActionTypeUnknown: @@ -255,6 +349,80 @@ Use this tool also to get details about individual workflows, workflow runs, job } } +func ActionsRunTrigger(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("actions_run_trigger", + mcp.WithDescription(t("TOOL_ACTIONS_TRIGGER_DESCRIPTION", "Trigger GitHub Actions workflow actions for a specific workflow run.")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_ACTIONS_TRIGGER_USER_TITLE", "Trigger GitHub Actions workflow actions for a specific workflow run."), + ReadOnlyHint: ToBoolPtr(false), + }), + mcp.WithString("action", + mcp.Required(), + mcp.Description("The action to trigger"), + mcp.Enum( + actionsActionTypeRerunWorkflowRun.String(), + actionsActionTypeRerunFailedJobs.String(), + actionsActionTypeCancelWorkflowRun.String(), + ), + ), + mcp.WithNumber("run_id", + mcp.Required(), + mcp.Description("The ID of the workflow run to trigger"), + ), + mcp.WithString("owner", + mcp.Required(), + mcp.Description(DescriptionRepositoryOwner), + ), + mcp.WithString("repo", + mcp.Required(), + mcp.Description(DescriptionRepositoryName), + ), + ), + 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 + } + actionTypeStr, err := RequiredParam[string](request, "action") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + resourceType := actionFromString(actionTypeStr) + if resourceType == actionsActionTypeUnknown { + return mcp.NewToolResultError(fmt.Sprintf("unknown action: %s", actionTypeStr)), nil + } + + runID, err := RequiredInt(request, "run_id") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + client, err := getClient(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + + switch resourceType { + case actionsActionTypeRerunWorkflowRun: + return rerunWorkflowRun(ctx, client, request, owner, repo, int64(runID)) + case actionsActionTypeRerunFailedJobs: + return rerunFailedJobs(ctx, client, request, owner, repo, int64(runID)) + case actionsActionTypeCancelWorkflowRun: + return cancelWorkflowRun(ctx, client, request, owner, repo, int64(runID)) + case actionsActionTypeUnknown: + return mcp.NewToolResultError(fmt.Sprintf("unknown action: %s", actionTypeStr)), nil + default: + // Should not reach here + return mcp.NewToolResultError("unhandled action type"), nil + } + } +} + func getWorkflow(ctx context.Context, client *github.Client, _ mcp.CallToolRequest, owner, repo string, resourceID string) (*mcp.CallToolResult, error) { var workflow *github.Workflow var resp *github.Response @@ -875,194 +1043,74 @@ func downloadLogContent(ctx context.Context, logURL string, tailLines int, maxLi } // RerunWorkflowRun creates a tool to re-run an entire workflow run -func RerunWorkflowRun(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { - return mcp.NewTool("rerun_workflow_run", - mcp.WithDescription(t("TOOL_RERUN_WORKFLOW_RUN_DESCRIPTION", "Re-run an entire workflow run")), - mcp.WithToolAnnotation(mcp.ToolAnnotation{ - Title: t("TOOL_RERUN_WORKFLOW_RUN_USER_TITLE", "Rerun workflow run"), - ReadOnlyHint: ToBoolPtr(false), - }), - mcp.WithString("owner", - mcp.Required(), - mcp.Description(DescriptionRepositoryOwner), - ), - mcp.WithString("repo", - mcp.Required(), - mcp.Description(DescriptionRepositoryName), - ), - mcp.WithNumber("run_id", - mcp.Required(), - mcp.Description("The unique identifier of the workflow run"), - ), - ), - 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 - } - runIDInt, err := RequiredInt(request, "run_id") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - runID := int64(runIDInt) - - client, err := getClient(ctx) - if err != nil { - return nil, fmt.Errorf("failed to get GitHub client: %w", err) - } - - resp, err := client.Actions.RerunWorkflowByID(ctx, owner, repo, runID) - if err != nil { - return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to rerun workflow run", resp, err), nil - } - defer func() { _ = resp.Body.Close() }() +func rerunWorkflowRun(ctx context.Context, client *github.Client, _ mcp.CallToolRequest, owner, repo string, runID int64) (*mcp.CallToolResult, error) { + resp, err := client.Actions.RerunWorkflowByID(ctx, owner, repo, runID) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to rerun workflow run", resp, err), nil + } + defer func() { _ = resp.Body.Close() }() - result := map[string]any{ - "message": "Workflow run has been queued for re-run", - "run_id": runID, - "status": resp.Status, - "status_code": resp.StatusCode, - } + result := map[string]any{ + "message": "Workflow run has been queued for re-run", + "run_id": runID, + "status": resp.Status, + "status_code": resp.StatusCode, + } - r, err := json.Marshal(result) - if err != nil { - return nil, fmt.Errorf("failed to marshal response: %w", err) - } + r, err := json.Marshal(result) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } - return mcp.NewToolResultText(string(r)), nil - } + return mcp.NewToolResultText(string(r)), nil } // RerunFailedJobs creates a tool to re-run only the failed jobs in a workflow run -func RerunFailedJobs(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { - return mcp.NewTool("rerun_failed_jobs", - mcp.WithDescription(t("TOOL_RERUN_FAILED_JOBS_DESCRIPTION", "Re-run only the failed jobs in a workflow run")), - mcp.WithToolAnnotation(mcp.ToolAnnotation{ - Title: t("TOOL_RERUN_FAILED_JOBS_USER_TITLE", "Rerun failed jobs"), - ReadOnlyHint: ToBoolPtr(false), - }), - mcp.WithString("owner", - mcp.Required(), - mcp.Description(DescriptionRepositoryOwner), - ), - mcp.WithString("repo", - mcp.Required(), - mcp.Description(DescriptionRepositoryName), - ), - mcp.WithNumber("run_id", - mcp.Required(), - mcp.Description("The unique identifier of the workflow run"), - ), - ), - 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 - } - runIDInt, err := RequiredInt(request, "run_id") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - runID := int64(runIDInt) - - client, err := getClient(ctx) - if err != nil { - return nil, fmt.Errorf("failed to get GitHub client: %w", err) - } - - resp, err := client.Actions.RerunFailedJobsByID(ctx, owner, repo, runID) - if err != nil { - return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to rerun failed jobs", resp, err), nil - } - defer func() { _ = resp.Body.Close() }() +func rerunFailedJobs(ctx context.Context, client *github.Client, _ mcp.CallToolRequest, owner, repo string, runID int64) (*mcp.CallToolResult, error) { + resp, err := client.Actions.RerunFailedJobsByID(ctx, owner, repo, runID) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to rerun failed jobs", resp, err), nil + } + defer func() { _ = resp.Body.Close() }() - result := map[string]any{ - "message": "Failed jobs have been queued for re-run", - "run_id": runID, - "status": resp.Status, - "status_code": resp.StatusCode, - } + result := map[string]any{ + "message": "Failed jobs have been queued for re-run", + "run_id": runID, + "status": resp.Status, + "status_code": resp.StatusCode, + } - r, err := json.Marshal(result) - if err != nil { - return nil, fmt.Errorf("failed to marshal response: %w", err) - } + r, err := json.Marshal(result) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } - return mcp.NewToolResultText(string(r)), nil - } + return mcp.NewToolResultText(string(r)), nil } // CancelWorkflowRun creates a tool to cancel a workflow run -func CancelWorkflowRun(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { - return mcp.NewTool("cancel_workflow_run", - mcp.WithDescription(t("TOOL_CANCEL_WORKFLOW_RUN_DESCRIPTION", "Cancel a workflow run")), - mcp.WithToolAnnotation(mcp.ToolAnnotation{ - Title: t("TOOL_CANCEL_WORKFLOW_RUN_USER_TITLE", "Cancel workflow run"), - ReadOnlyHint: ToBoolPtr(false), - }), - mcp.WithString("owner", - mcp.Required(), - mcp.Description(DescriptionRepositoryOwner), - ), - mcp.WithString("repo", - mcp.Required(), - mcp.Description(DescriptionRepositoryName), - ), - mcp.WithNumber("run_id", - mcp.Required(), - mcp.Description("The unique identifier of the workflow run"), - ), - ), - 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 - } - runIDInt, err := RequiredInt(request, "run_id") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - runID := int64(runIDInt) - - client, err := getClient(ctx) - if err != nil { - return nil, fmt.Errorf("failed to get GitHub client: %w", err) - } - - resp, err := client.Actions.CancelWorkflowRunByID(ctx, owner, repo, runID) - if err != nil { - if _, ok := err.(*github.AcceptedError); !ok { - return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to cancel workflow run", resp, err), nil - } - } - defer func() { _ = resp.Body.Close() }() +func cancelWorkflowRun(ctx context.Context, client *github.Client, _ mcp.CallToolRequest, owner, repo string, runID int64) (*mcp.CallToolResult, error) { + resp, err := client.Actions.CancelWorkflowRunByID(ctx, owner, repo, runID) + if err != nil { + if _, ok := err.(*github.AcceptedError); !ok { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to cancel workflow run", resp, err), nil + } + } + defer func() { _ = resp.Body.Close() }() - result := map[string]any{ - "message": "Workflow run has been cancelled", - "run_id": runID, - "status": resp.Status, - "status_code": resp.StatusCode, - } + result := map[string]any{ + "message": "Workflow run has been cancelled", + "run_id": runID, + "status": resp.Status, + "status_code": resp.StatusCode, + } - r, err := json.Marshal(result) - if err != nil { - return nil, fmt.Errorf("failed to marshal response: %w", err) - } + r, err := json.Marshal(result) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } - return mcp.NewToolResultText(string(r)), nil - } + return mcp.NewToolResultText(string(r)), nil } // DeleteWorkflowRunLogs creates a tool to delete logs for a workflow run diff --git a/pkg/github/actions_test.go b/pkg/github/actions_test.go index 7e8a55f0d..69a664c48 100644 --- a/pkg/github/actions_test.go +++ b/pkg/github/actions_test.go @@ -23,7 +23,7 @@ import ( "github.com/stretchr/testify/require" ) -func Test_ActionsRead_ListWorkflows(t *testing.T) { +func Test_ActionsList_ListWorkflows(t *testing.T) { tests := []struct { name string mockedClient *http.Client @@ -94,7 +94,7 @@ func Test_ActionsRead_ListWorkflows(t *testing.T) { t.Run(tc.name, func(t *testing.T) { // Setup client with mock client := github.NewClient(tc.mockedClient) - _, handler := ActionsRead(stubGetClientFn(client), translations.NullTranslationHelper) + _, handler := ActionsList(stubGetClientFn(client), translations.NullTranslationHelper) // Create call request request := createMCPRequest(tc.requestArgs) @@ -300,18 +300,21 @@ func Test_RunWorkflow_WithFilename(t *testing.T) { } } -func Test_CancelWorkflowRun(t *testing.T) { +func Test_ActionsRunTrigger(t *testing.T) { // Verify tool definition once mockClient := github.NewClient(nil) - tool, _ := CancelWorkflowRun(stubGetClientFn(mockClient), translations.NullTranslationHelper) + tool, _ := ActionsRunTrigger(stubGetClientFn(mockClient), translations.NullTranslationHelper) - assert.Equal(t, "cancel_workflow_run", tool.Name) + assert.Equal(t, "actions_run_trigger", tool.Name) assert.NotEmpty(t, tool.Description) + assert.Contains(t, tool.InputSchema.Properties, "action") assert.Contains(t, tool.InputSchema.Properties, "owner") assert.Contains(t, tool.InputSchema.Properties, "repo") assert.Contains(t, tool.InputSchema.Properties, "run_id") - assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "run_id"}) + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"action", "owner", "repo", "run_id"}) +} +func Test_CancelWorkflowRun(t *testing.T) { tests := []struct { name string mockedClient *http.Client @@ -333,6 +336,7 @@ func Test_CancelWorkflowRun(t *testing.T) { ), ), requestArgs: map[string]any{ + "action": actionsActionTypeCancelWorkflowRun.String(), "owner": "owner", "repo": "repo", "run_id": float64(12345), @@ -353,6 +357,7 @@ func Test_CancelWorkflowRun(t *testing.T) { ), ), requestArgs: map[string]any{ + "action": actionsActionTypeCancelWorkflowRun.String(), "owner": "owner", "repo": "repo", "run_id": float64(12345), @@ -364,8 +369,9 @@ func Test_CancelWorkflowRun(t *testing.T) { name: "missing required parameter run_id", mockedClient: mock.NewMockedHTTPClient(), requestArgs: map[string]any{ - "owner": "owner", - "repo": "repo", + "action": actionsActionTypeCancelWorkflowRun.String(), + "owner": "owner", + "repo": "repo", }, expectError: true, expectedErrMsg: "missing required parameter: run_id", @@ -376,7 +382,7 @@ func Test_CancelWorkflowRun(t *testing.T) { t.Run(tc.name, func(t *testing.T) { // Setup client with mock client := github.NewClient(tc.mockedClient) - _, handler := CancelWorkflowRun(stubGetClientFn(client), translations.NullTranslationHelper) + _, handler := ActionsRunTrigger(stubGetClientFn(client), translations.NullTranslationHelper) // Create call request request := createMCPRequest(tc.requestArgs) @@ -492,7 +498,7 @@ func Test_ListWorkflowRunArtifacts(t *testing.T) { t.Run(tc.name, func(t *testing.T) { // Setup client with mock client := github.NewClient(tc.mockedClient) - _, handler := ActionsRead(stubGetClientFn(client), translations.NullTranslationHelper) + _, handler := ActionsList(stubGetClientFn(client), translations.NullTranslationHelper) // Create call request request := createMCPRequest(tc.requestArgs) @@ -562,7 +568,7 @@ func Test_DownloadWorkflowRunArtifact(t *testing.T) { "repo": "repo", }, expectError: true, - expectedErrMsg: fmt.Sprintf("missing required parameter for action %s: resource_id", actionsActionTypeDownloadWorkflowArtifact.String()), + expectedErrMsg: "missing required parameter: resource_id", }, } @@ -570,7 +576,7 @@ func Test_DownloadWorkflowRunArtifact(t *testing.T) { t.Run(tc.name, func(t *testing.T) { // Setup client with mock client := github.NewClient(tc.mockedClient) - _, handler := ActionsRead(stubGetClientFn(client), translations.NullTranslationHelper) + _, handler := ActionsGet(stubGetClientFn(client), translations.NullTranslationHelper) // Create call request request := createMCPRequest(tc.requestArgs) @@ -718,7 +724,7 @@ func Test_GetWorkflowRunUsage(t *testing.T) { expectError: false, }, { - name: "missing required parameter run_id", + name: "missing required parameter resource_id", mockedClient: mock.NewMockedHTTPClient(), requestArgs: map[string]any{ "action": actionsActionTypeGetWorkflowRunUsage.String(), @@ -726,7 +732,7 @@ func Test_GetWorkflowRunUsage(t *testing.T) { "repo": "repo", }, expectError: true, - expectedErrMsg: fmt.Sprintf("missing required parameter for action %s: resource_id", actionsActionTypeGetWorkflowRunUsage.String()), + expectedErrMsg: "missing required parameter: resource_id", }, } @@ -734,7 +740,7 @@ func Test_GetWorkflowRunUsage(t *testing.T) { t.Run(tc.name, func(t *testing.T) { // Setup client with mock client := github.NewClient(tc.mockedClient) - _, handler := ActionsRead(stubGetClientFn(client), translations.NullTranslationHelper) + _, handler := ActionsGet(stubGetClientFn(client), translations.NullTranslationHelper) // Create call request request := createMCPRequest(tc.requestArgs) @@ -1272,18 +1278,18 @@ func Test_MemoryUsage_SlidingWindow_vs_NoWindow(t *testing.T) { t.Logf("No window: %s", profile2.String()) } -func Test_ActionsResourceRead(t *testing.T) { +func Test_ActionsGet(t *testing.T) { // Verify tool definition once mockClient := github.NewClient(nil) - tool, _ := ActionsRead(stubGetClientFn(mockClient), translations.NullTranslationHelper) + tool, _ := ActionsGet(stubGetClientFn(mockClient), translations.NullTranslationHelper) - assert.Equal(t, "actions_read", tool.Name) + assert.Equal(t, "actions_get", tool.Name) assert.NotEmpty(t, tool.Description) assert.Contains(t, tool.InputSchema.Properties, "action") assert.Contains(t, tool.InputSchema.Properties, "owner") assert.Contains(t, tool.InputSchema.Properties, "repo") assert.Contains(t, tool.InputSchema.Properties, "resource_id") - assert.ElementsMatch(t, tool.InputSchema.Required, []string{"action", "owner", "repo"}) + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"action", "owner", "repo", "resource_id"}) tests := []struct { name string @@ -1325,6 +1331,17 @@ func Test_ActionsResourceRead(t *testing.T) { expectError: true, expectedErrMsg: "missing required parameter: repo", }, + { + name: "missing required parameter resource_id", + mockedClient: mock.NewMockedHTTPClient(), + requestArgs: map[string]any{ + "action": actionsActionTypeGetWorkflow.String(), + "owner": "owner", + "repo": "repo", + }, + expectError: true, + expectedErrMsg: "missing required parameter: resource_id", + }, { name: "unknown resource", mockedClient: mock.NewMockedHTTPClient(), @@ -1343,7 +1360,7 @@ func Test_ActionsResourceRead(t *testing.T) { t.Run(tc.name, func(t *testing.T) { // Setup client with mock client := github.NewClient(tc.mockedClient) - _, handler := ActionsRead(stubGetClientFn(client), translations.NullTranslationHelper) + _, handler := ActionsGet(stubGetClientFn(client), translations.NullTranslationHelper) // Create call request request := createMCPRequest(tc.requestArgs) @@ -1425,7 +1442,7 @@ func Test_ActionsResourceRead_GetWorkflow(t *testing.T) { t.Run(tc.name, func(t *testing.T) { // Setup client with mock client := github.NewClient(tc.mockedClient) - _, handler := ActionsRead(stubGetClientFn(client), translations.NullTranslationHelper) + _, handler := ActionsGet(stubGetClientFn(client), translations.NullTranslationHelper) // Create call request request := createMCPRequest(tc.requestArgs) @@ -1520,7 +1537,7 @@ func Test_ActionsResourceRead_GetWorkflowRun(t *testing.T) { t.Run(tc.name, func(t *testing.T) { // Setup client with mock client := github.NewClient(tc.mockedClient) - _, handler := ActionsRead(stubGetClientFn(client), translations.NullTranslationHelper) + _, handler := ActionsGet(stubGetClientFn(client), translations.NullTranslationHelper) // Create call request request := createMCPRequest(tc.requestArgs) @@ -1672,7 +1689,7 @@ func Test_ActionsResourceRead_ListWorkflowRuns(t *testing.T) { t.Run(tc.name, func(t *testing.T) { // Setup client with mock client := github.NewClient(tc.mockedClient) - _, handler := ActionsRead(stubGetClientFn(client), translations.NullTranslationHelper) + _, handler := ActionsList(stubGetClientFn(client), translations.NullTranslationHelper) // Create call request request := createMCPRequest(tc.requestArgs) @@ -1758,7 +1775,7 @@ func Test_ActionsResourceRead_GetWorkflowJob(t *testing.T) { t.Run(tc.name, func(t *testing.T) { // Setup client with mock client := github.NewClient(tc.mockedClient) - _, handler := ActionsRead(stubGetClientFn(client), translations.NullTranslationHelper) + _, handler := ActionsGet(stubGetClientFn(client), translations.NullTranslationHelper) // Create call request request := createMCPRequest(tc.requestArgs) @@ -1855,7 +1872,7 @@ func Test_ActionsResourceRead_ListWorkflowJobs(t *testing.T) { t.Run(tc.name, func(t *testing.T) { // Setup client with mock client := github.NewClient(tc.mockedClient) - _, handler := ActionsRead(stubGetClientFn(client), translations.NullTranslationHelper) + _, handler := ActionsList(stubGetClientFn(client), translations.NullTranslationHelper) // Create call request request := createMCPRequest(tc.requestArgs) @@ -1935,7 +1952,7 @@ func Test_ActionsResourceRead_DownloadWorkflowArtifact(t *testing.T) { t.Run(tc.name, func(t *testing.T) { // Setup client with mock client := github.NewClient(tc.mockedClient) - _, handler := ActionsRead(stubGetClientFn(client), translations.NullTranslationHelper) + _, handler := ActionsGet(stubGetClientFn(client), translations.NullTranslationHelper) // Create call request request := createMCPRequest(tc.requestArgs) @@ -2028,7 +2045,7 @@ func Test_ActionsResourceRead_ListWorkflowArtifacts(t *testing.T) { t.Run(tc.name, func(t *testing.T) { // Setup client with mock client := github.NewClient(tc.mockedClient) - _, handler := ActionsRead(stubGetClientFn(client), translations.NullTranslationHelper) + _, handler := ActionsList(stubGetClientFn(client), translations.NullTranslationHelper) // Create call request request := createMCPRequest(tc.requestArgs) @@ -2123,7 +2140,7 @@ func Test_ActionsResourceRead_GetWorkflowUsage(t *testing.T) { t.Run(tc.name, func(t *testing.T) { // Setup client with mock client := github.NewClient(tc.mockedClient) - _, handler := ActionsRead(stubGetClientFn(client), translations.NullTranslationHelper) + _, handler := ActionsGet(stubGetClientFn(client), translations.NullTranslationHelper) // Create call request request := createMCPRequest(tc.requestArgs) diff --git a/pkg/github/tools.go b/pkg/github/tools.go index cf9573b8e..2f56d855f 100644 --- a/pkg/github/tools.go +++ b/pkg/github/tools.go @@ -269,22 +269,14 @@ func DefaultToolsetGroup(readOnly bool, getClient GetClientFn, getGQLClient GetG actions := toolsets.NewToolset(ToolsetMetadataActions.ID, ToolsetMetadataActions.Description). AddReadTools( - toolsets.NewServerTool(ActionsRead(getClient, t)), - // toolsets.NewServerTool(ListWorkflows(getClient, t)), - // toolsets.NewServerTool(ListWorkflowRuns(getClient, t)), - // toolsets.NewServerTool(GetWorkflowRun(getClient, t)), + toolsets.NewServerTool(ActionsGet(getClient, t)), + toolsets.NewServerTool(ActionsList(getClient, t)), toolsets.NewServerTool(GetWorkflowRunLogs(getClient, t)), - // toolsets.NewServerTool(ListWorkflowJobs(getClient, t)), toolsets.NewServerTool(GetJobLogs(getClient, t, contentWindowSize)), - // toolsets.NewServerTool(ListWorkflowRunArtifacts(getClient, t)), - // toolsets.NewServerTool(DownloadWorkflowRunArtifact(getClient, t)), - // toolsets.NewServerTool(GetWorkflowRunUsage(getClient, t)), ). AddWriteTools( toolsets.NewServerTool(RunWorkflow(getClient, t)), - toolsets.NewServerTool(RerunWorkflowRun(getClient, t)), - toolsets.NewServerTool(RerunFailedJobs(getClient, t)), - toolsets.NewServerTool(CancelWorkflowRun(getClient, t)), + toolsets.NewServerTool(ActionsRunTrigger(getClient, t)), toolsets.NewServerTool(DeleteWorkflowRunLogs(getClient, t)), ) From 617e44c3aa890a44c990d1b3e1e38de5d537c234 Mon Sep 17 00:00:00 2001 From: Adam Holt Date: Fri, 31 Oct 2025 16:38:14 +0100 Subject: [PATCH 21/22] Update description --- pkg/github/actions.go | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/pkg/github/actions.go b/pkg/github/actions.go index f6ecf0834..6cf3424fc 100644 --- a/pkg/github/actions.go +++ b/pkg/github/actions.go @@ -77,6 +77,9 @@ func actionFromString(s string) actionsActionType { func ActionsList(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { return mcp.NewTool("actions_list", mcp.WithDescription(t("TOOL_ACTIONS_LIST_DESCRIPTION", "List GitHub Actions workflows in a repository.")), + mcp.WithDescription(t("TOOL_ACTIONS_LIST_DESCRIPTION", `Tools for listing GitHub Actions resources. +Use this tool to list workflows in a repository, or list workflow runs, jobs, and artifacts for a specific workflow or workflow run. +`)), mcp.WithToolAnnotation(mcp.ToolAnnotation{ Title: t("TOOL_ACTIONS_LIST_USER_TITLE", "List GitHub Actions workflows in a repository"), ReadOnlyHint: ToBoolPtr(true), @@ -247,9 +250,8 @@ func ActionsList(getClient GetClientFn, t translations.TranslationHelperFunc) (t func ActionsGet(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { return mcp.NewTool("actions_get", - mcp.WithDescription(t("TOOL_ACTIONS_READ_DESCRIPTION", `Tools for reading GitHub Actions resources. -Use this tool to list workflows in a repository, or list workflow runs, jobs, and artifacts for a specific workflow or workflow run. -Use this tool also to get details about individual workflows, workflow runs, jobs, and artifacts, by using their unique IDs. + mcp.WithDescription(t("TOOL_ACTIONS_READ_DESCRIPTION", `Tools for reading specific GitHub Actions resources. +Use this tool to get details about individual workflows, workflow runs, jobs, and artifacts, by using their unique IDs. `)), mcp.WithToolAnnotation(mcp.ToolAnnotation{ Title: t("TOOL_ACTIONS_READ_USER_TITLE", "Get details of GitHub Actions resources (workflows, workflow runs, jobs, and artifacts)"), From 7028be0281c05914e363fc020b7dd40c029514b4 Mon Sep 17 00:00:00 2001 From: Adam Holt Date: Fri, 31 Oct 2025 17:12:19 +0100 Subject: [PATCH 22/22] Improve descriptions to be clearer The filters don't return something new, they filter existing results. --- pkg/github/actions.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/pkg/github/actions.go b/pkg/github/actions.go index 6cf3424fc..12ee7de90 100644 --- a/pkg/github/actions.go +++ b/pkg/github/actions.go @@ -114,15 +114,15 @@ Use this tool to list workflows in a repository, or list workflow runs, jobs, an mcp.Properties(map[string]any{ "actor": map[string]any{ "type": "string", - "description": "Returns someone's workflow runs. Use the login for the user who created the workflow run.", + "description": "Filter to a specific GitHub user's workflow runs.", }, "branch": map[string]any{ "type": "string", - "description": "Returns workflow runs associated with a branch. Use the name of the branch.", + "description": "Filter workflow runs to a specific Git branch. Use the name of the branch.", }, "event": map[string]any{ "type": "string", - "description": "Returns workflow runs for a specific event type", + "description": "Filter workflow runs to a specific event type", "enum": []string{ "branch_protection_rule", "check_run", @@ -160,7 +160,7 @@ Use this tool to list workflows in a repository, or list workflow runs, jobs, an }, "status": map[string]any{ "type": "string", - "description": "Returns workflow runs with the check run status", + "description": "Filter workflow runs to only runs with a specific status", "enum": []string{"queued", "in_progress", "completed", "requested", "waiting"}, }, }),