From 219c3bb00266d611b8be2d1206ab5753a1890b15 Mon Sep 17 00:00:00 2001 From: gabornyergesX Date: Sun, 8 Jun 2025 16:02:03 +0200 Subject: [PATCH 01/13] feat: add GitHub Actions tools for workflow management - Introduced new tools for managing GitHub Actions workflows, including listing workflows, running workflows, canceling workflow runs, and retrieving workflow run logs. - Updated README.md to include new `actions` toolset and detailed descriptions of the new tools. - Added comprehensive tests for the new functionality to ensure reliability and correctness. --- README.md | 107 +++- pkg/github/actions.go | 981 +++++++++++++++++++++++++++++++++++++ pkg/github/actions_test.go | 698 ++++++++++++++++++++++++++ 3 files changed, 1783 insertions(+), 3 deletions(-) create mode 100644 pkg/github/actions.go create mode 100644 pkg/github/actions_test.go diff --git a/README.md b/README.md index 003164e0..500590ca 100644 --- a/README.md +++ b/README.md @@ -210,12 +210,12 @@ To specify toolsets you want available to the LLM, you can pass an allow-list in 1. **Using Command Line Argument**: ```bash - github-mcp-server --toolsets repos,issues,pull_requests,code_security + github-mcp-server --toolsets repos,issues,pull_requests,actions,code_security ``` 2. **Using Environment Variable**: ```bash - GITHUB_TOOLSETS="repos,issues,pull_requests,code_security" ./github-mcp-server + GITHUB_TOOLSETS="repos,issues,pull_requests,actions,code_security" ./github-mcp-server ``` The environment variable `GITHUB_TOOLSETS` takes precedence over the command line argument if both are provided. @@ -227,7 +227,7 @@ When using Docker, you can pass the toolsets as environment variables: ```bash docker run -i --rm \ -e GITHUB_PERSONAL_ACCESS_TOKEN= \ - -e GITHUB_TOOLSETS="repos,issues,pull_requests,code_security,experiments" \ + -e GITHUB_TOOLSETS="repos,issues,pull_requests,actions,code_security,experiments" \ ghcr.io/github/github-mcp-server ``` @@ -617,6 +617,107 @@ export GITHUB_MCP_TOOL_ADD_ISSUE_COMMENT_DESCRIPTION="an alternative description - `page`: Page number (number, optional) - `perPage`: Results per page (number, optional) +### Actions + +- **list_workflows** - List workflows in a repository + + - `owner`: Repository owner (string, required) + - `repo`: Repository name (string, required) + - `page`: Page number (number, optional) + - `perPage`: Results per page (number, optional) + +- **list_workflow_runs** - List workflow runs for a specific workflow + + - `owner`: Repository owner (string, required) + - `repo`: Repository name (string, required) + - `workflow_id`: Workflow ID or filename (string, required) + - `branch`: Filter by branch name (string, optional) + - `event`: Filter by event type (string, optional) + - `status`: Filter by run status (string, optional) + - `page`: Page number (number, optional) + - `perPage`: Results per page (number, optional) + +- **run_workflow** - Trigger a workflow via workflow_dispatch event + + - `owner`: Repository owner (string, required) + - `repo`: Repository name (string, required) + - `workflow_id`: Workflow ID or filename (string, required) + - `ref`: Git reference (branch, tag, or SHA) (string, required) + - `inputs`: Input parameters for the workflow (object, optional) + +- **get_workflow_run** - Get details of a specific workflow run + + - `owner`: Repository owner (string, required) + - `repo`: Repository name (string, required) + - `run_id`: Workflow run ID (number, required) + +- **get_workflow_run_logs** - Download logs for a workflow run + + - `owner`: Repository owner (string, required) + - `repo`: Repository name (string, required) + - `run_id`: Workflow run ID (number, required) + +- **list_workflow_jobs** - List jobs for a workflow run + + - `owner`: Repository owner (string, required) + - `repo`: Repository name (string, required) + - `run_id`: Workflow run ID (number, required) + - `filter`: Filter by job status (string, optional) + - `page`: Page number (number, optional) + - `perPage`: Results per page (number, optional) + +- **get_job_logs** - Download logs for a specific job + + - `owner`: Repository owner (string, required) + - `repo`: Repository name (string, required) + - `job_id`: Job ID (number, required) + +- **rerun_workflow_run** - Re-run an entire workflow + + - `owner`: Repository owner (string, required) + - `repo`: Repository name (string, required) + - `run_id`: Workflow run ID (number, required) + - `enable_debug_logging`: Enable debug logging for the re-run (boolean, optional) + +- **rerun_failed_jobs** - Re-run only the failed jobs in a workflow run + + - `owner`: Repository owner (string, required) + - `repo`: Repository name (string, required) + - `run_id`: Workflow run ID (number, required) + - `enable_debug_logging`: Enable debug logging for the re-run (boolean, optional) + +- **cancel_workflow_run** - Cancel a running workflow + + - `owner`: Repository owner (string, required) + - `repo`: Repository name (string, required) + - `run_id`: Workflow run ID (number, required) + +- **list_workflow_run_artifacts** - List artifacts from a workflow run + + - `owner`: Repository owner (string, required) + - `repo`: Repository name (string, required) + - `run_id`: Workflow run ID (number, required) + - `page`: Page number (number, optional) + - `perPage`: Results per page (number, optional) + +- **download_workflow_run_artifact** - Get download URL for a specific artifact + + - `owner`: Repository owner (string, required) + - `repo`: Repository name (string, required) + - `artifact_id`: Artifact ID (number, required) + +- **delete_workflow_run_logs** - Delete logs for a workflow run + + - `owner`: Repository owner (string, required) + - `repo`: Repository name (string, required) + - `run_id`: Workflow run ID (number, required) + +- **get_workflow_run_usage** - Get usage metrics for a workflow run + + - `owner`: Repository owner (string, required) + - `repo`: Repository name (string, required) + - `run_id`: Workflow run ID (number, required) + ### Code Scanning - **get_code_scanning_alert** - Get a code scanning alert diff --git a/pkg/github/actions.go b/pkg/github/actions.go new file mode 100644 index 00000000..c791212e --- /dev/null +++ b/pkg/github/actions.go @@ -0,0 +1,981 @@ +package github + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/github/github-mcp-server/pkg/translations" + "github.com/google/go-github/v72/github" + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" +) + +// 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{ + ReadOnlyHint: toBoolPtr(true), + }), + mcp.WithString("owner", + mcp.Required(), + mcp.Description("The account owner of the repository. The name is not case sensitive."), + ), + mcp.WithString("repo", + mcp.Required(), + mcp.Description("Repository name"), + ), + mcp.WithNumber("per_page", + mcp.Description("The number of results per page (max 100)"), + ), + mcp.WithNumber("page", + mcp.Description("The page number of the results to fetch"), + ), + ), + 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 + perPage, err := OptionalIntParam(request, "per_page") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + page, err := OptionalIntParam(request, "page") + 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: perPage, + Page: 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() }() + + r, err := json.Marshal(workflows) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil + } +} + +// 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{ + ReadOnlyHint: toBoolPtr(true), + }), + mcp.WithString("owner", + mcp.Required(), + mcp.Description("The account owner of the repository. The name is not case sensitive."), + ), + mcp.WithString("repo", + mcp.Required(), + mcp.Description("Repository name"), + ), + mcp.WithString("workflow_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 an event. For example, push, pull_request, or issue."), + ), + mcp.WithString("status", + mcp.Description("Returns workflow runs with the check run status. For example, completed, in_progress, or requested."), + ), + mcp.WithNumber("per_page", + mcp.Description("The number of results per page (max 100)"), + ), + mcp.WithNumber("page", + mcp.Description("The page number of the results to fetch"), + ), + ), + 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 + perPage, err := OptionalIntParam(request, "per_page") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + page, err := OptionalIntParam(request, "page") + 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: perPage, + Page: 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", + mcp.WithDescription(t("TOOL_RUN_WORKFLOW_DESCRIPTION", "Run an Actions workflow")), + mcp.WithString("owner", + mcp.Required(), + mcp.Description("The account owner of the repository. The name is not case sensitive."), + ), + mcp.WithString("repo", + mcp.Required(), + mcp.Description("Repository name"), + ), + mcp.WithString("workflow_file", + mcp.Required(), + mcp.Description("The workflow ID or workflow file name"), + ), + mcp.WithString("ref", + mcp.Required(), + mcp.Description("The git reference for the workflow. The reference can be a branch or tag name."), + ), + mcp.WithObject("inputs", + mcp.Description("Inputs the workflow accepts"), + ), + ), + 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 + } + workflowFile, err := requiredParam[string](request, "workflow_file") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + ref, err := requiredParam[string](request, "ref") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + // Get optional inputs parameter + var inputs map[string]interface{} + if requestInputs, ok := request.GetArguments()["inputs"]; ok { + if inputsMap, ok := requestInputs.(map[string]interface{}); ok { + inputs = inputsMap + } + } + + client, err := getClient(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + + event := github.CreateWorkflowDispatchEventRequest{ + Ref: ref, + Inputs: inputs, + } + + resp, err := client.Actions.CreateWorkflowDispatchEventByFileName(ctx, owner, repo, workflowFile, event) + if err != nil { + return nil, fmt.Errorf("failed to run workflow: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + result := map[string]any{ + "message": "Workflow run has been queued", + "workflow": workflowFile, + "ref": ref, + "inputs": inputs, + "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) + } + + return mcp.NewToolResultText(string(r)), nil + } +} + +// 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{ + ReadOnlyHint: toBoolPtr(true), + }), + mcp.WithString("owner", + mcp.Required(), + mcp.Description("The account owner of the repository. The name is not case sensitive."), + ), + mcp.WithString("repo", + mcp.Required(), + mcp.Description("Repository name"), + ), + mcp.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", + mcp.WithDescription(t("TOOL_GET_WORKFLOW_RUN_LOGS_DESCRIPTION", "Download logs for a specific workflow run")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + ReadOnlyHint: toBoolPtr(true), + }), + mcp.WithString("owner", + mcp.Required(), + mcp.Description("The account owner of the repository. The name is not case sensitive."), + ), + mcp.WithString("repo", + mcp.Required(), + mcp.Description("Repository name"), + ), + mcp.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) + } + + // Get the download URL for the logs + url, resp, err := client.Actions.GetWorkflowRunLogs(ctx, owner, repo, runID, 1) + if err != nil { + return nil, fmt.Errorf("failed to get workflow run logs: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + // Create response with the logs URL and information + result := map[string]any{ + "logs_url": url.String(), + "message": "Workflow run logs are available for download", + "note": "The logs_url provides a download link for the complete workflow run logs as a ZIP archive. You can download this archive to extract and examine individual job logs.", + } + + r, err := json.Marshal(result) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil + } +} + +// 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{ + ReadOnlyHint: toBoolPtr(true), + }), + mcp.WithString("owner", + mcp.Required(), + mcp.Description("The account owner of the repository. The name is not case sensitive."), + ), + mcp.WithString("repo", + mcp.Required(), + mcp.Description("Repository name"), + ), + mcp.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. Can be one of: latest, all"), + ), + mcp.WithNumber("per_page", + mcp.Description("The number of results per page (max 100)"), + ), + mcp.WithNumber("page", + mcp.Description("The page number of the results to fetch"), + ), + ), + 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 + perPage, err := OptionalIntParam(request, "per_page") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + page, err := OptionalIntParam(request, "page") + 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: perPage, + Page: 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() }() + + r, err := json.Marshal(jobs) + 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 +func GetJobLogs(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("get_job_logs", + mcp.WithDescription(t("TOOL_GET_JOB_LOGS_DESCRIPTION", "Download logs for a specific workflow job")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + ReadOnlyHint: toBoolPtr(true), + }), + mcp.WithString("owner", + mcp.Required(), + mcp.Description("The account owner of the repository. The name is not case sensitive."), + ), + mcp.WithString("repo", + mcp.Required(), + mcp.Description("Repository name"), + ), + mcp.WithNumber("job_id", + mcp.Required(), + mcp.Description("The unique identifier of the workflow job"), + ), + ), + 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 + } + jobIDInt, err := RequiredInt(request, "job_id") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + jobID := int64(jobIDInt) + + client, err := getClient(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + + // Get the download URL for the job logs + url, resp, err := client.Actions.GetWorkflowJobLogs(ctx, owner, repo, jobID, 1) + if err != nil { + return nil, fmt.Errorf("failed to get job logs: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + // Create response with the logs URL and information + result := map[string]any{ + "logs_url": url.String(), + "message": "Job logs are available for download", + "note": "The logs_url provides a download link for the individual job logs in plain text format. This is more targeted than workflow run logs and easier to read for debugging specific failed steps.", + } + + r, err := json.Marshal(result) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil + } +} + +// 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.WithString("owner", + mcp.Required(), + mcp.Description("The account owner of the repository. The name is not case sensitive."), + ), + mcp.WithString("repo", + mcp.Required(), + mcp.Description("Repository name"), + ), + mcp.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 nil, fmt.Errorf("failed to rerun workflow run: %w", err) + } + 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, + } + + r, err := json.Marshal(result) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } + + 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.WithString("owner", + mcp.Required(), + mcp.Description("The account owner of the repository. The name is not case sensitive."), + ), + mcp.WithString("repo", + mcp.Required(), + mcp.Description("Repository name"), + ), + mcp.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 nil, fmt.Errorf("failed to rerun failed jobs: %w", err) + } + 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, + } + + r, err := json.Marshal(result) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } + + 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.WithString("owner", + mcp.Required(), + mcp.Description("The account owner of the repository. The name is not case sensitive."), + ), + mcp.WithString("repo", + mcp.Required(), + mcp.Description("Repository name"), + ), + mcp.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 { + return nil, fmt.Errorf("failed to cancel workflow run: %w", err) + } + 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, + } + + r, err := json.Marshal(result) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil + } +} + +// 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{ + ReadOnlyHint: toBoolPtr(true), + }), + mcp.WithString("owner", + mcp.Required(), + mcp.Description("The account owner of the repository. The name is not case sensitive."), + ), + mcp.WithString("repo", + mcp.Required(), + mcp.Description("Repository name"), + ), + mcp.WithNumber("run_id", + mcp.Required(), + mcp.Description("The unique identifier of the workflow run"), + ), + mcp.WithNumber("per_page", + mcp.Description("The number of results per page (max 100)"), + ), + mcp.WithNumber("page", + mcp.Description("The page number of the results to fetch"), + ), + ), + 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 + perPage, err := OptionalIntParam(request, "per_page") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + page, err := OptionalIntParam(request, "page") + 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: perPage, + Page: page, + } + + artifacts, resp, err := client.Actions.ListWorkflowRunArtifacts(ctx, owner, repo, runID, opts) + if err != nil { + return nil, fmt.Errorf("failed to list workflow run artifacts: %w", err) + } + 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{ + ReadOnlyHint: toBoolPtr(true), + }), + mcp.WithString("owner", + mcp.Required(), + mcp.Description("The account owner of the repository. The name is not case sensitive."), + ), + mcp.WithString("repo", + mcp.Required(), + mcp.Description("Repository name"), + ), + mcp.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 nil, fmt.Errorf("failed to get artifact download URL: %w", err) + } + 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", + mcp.WithDescription(t("TOOL_DELETE_WORKFLOW_RUN_LOGS_DESCRIPTION", "Delete logs for a workflow run")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + DestructiveHint: toBoolPtr(true), + }), + mcp.WithString("owner", + mcp.Required(), + mcp.Description("The account owner of the repository. The name is not case sensitive."), + ), + mcp.WithString("repo", + mcp.Required(), + mcp.Description("Repository name"), + ), + mcp.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.DeleteWorkflowRunLogs(ctx, owner, repo, runID) + if err != nil { + return nil, fmt.Errorf("failed to delete workflow run logs: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + result := map[string]any{ + "message": "Workflow run logs have been deleted", + "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) + } + + return mcp.NewToolResultText(string(r)), nil + } +} + +// 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{ + ReadOnlyHint: toBoolPtr(true), + }), + mcp.WithString("owner", + mcp.Required(), + mcp.Description("The account owner of the repository. The name is not case sensitive."), + ), + mcp.WithString("repo", + mcp.Required(), + mcp.Description("Repository name"), + ), + mcp.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 nil, fmt.Errorf("failed to get workflow run usage: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + r, err := json.Marshal(usage) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil + } +} diff --git a/pkg/github/actions_test.go b/pkg/github/actions_test.go new file mode 100644 index 00000000..f317c622 --- /dev/null +++ b/pkg/github/actions_test.go @@ -0,0 +1,698 @@ +package github + +import ( + "context" + "encoding/json" + "net/http" + "testing" + + "github.com/github/github-mcp-server/pkg/translations" + "github.com/google/go-github/v72/github" + "github.com/migueleliasweb/go-github-mock/src/mock" + "github.com/stretchr/testify/assert" + "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, "per_page") + assert.Contains(t, tool.InputSchema.Properties, "page") + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo"}) + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]any + expectError bool + expectedErrMsg string + }{ + { + name: "successful workflow listing", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposActionsWorkflowsByOwnerByRepo, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + workflows := &github.Workflows{ + TotalCount: github.Int(2), + Workflows: []*github.Workflow{ + { + ID: github.Int64(123), + Name: github.String("CI"), + Path: github.String(".github/workflows/ci.yml"), + State: github.String("active"), + CreatedAt: &github.Timestamp{}, + UpdatedAt: &github.Timestamp{}, + URL: github.String("https://api.github.com/repos/owner/repo/actions/workflows/123"), + HTMLURL: github.String("https://github.com/owner/repo/actions/workflows/ci.yml"), + BadgeURL: github.String("https://github.com/owner/repo/workflows/CI/badge.svg"), + NodeID: github.String("W_123"), + }, + { + ID: github.Int64(456), + Name: github.String("Deploy"), + Path: github.String(".github/workflows/deploy.yml"), + State: github.String("active"), + CreatedAt: &github.Timestamp{}, + UpdatedAt: &github.Timestamp{}, + URL: github.String("https://api.github.com/repos/owner/repo/actions/workflows/456"), + HTMLURL: github.String("https://github.com/owner/repo/actions/workflows/deploy.yml"), + BadgeURL: github.String("https://github.com/owner/repo/workflows/Deploy/badge.svg"), + NodeID: github.String("W_456"), + }, + }, + } + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(workflows) + }), + ), + ), + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + }, + expectError: false, + }, + { + name: "missing required parameter owner", + mockedClient: mock.NewMockedHTTPClient(), + requestArgs: map[string]any{ + "repo": "repo", + }, + expectError: true, + expectedErrMsg: "missing required parameter: owner", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Setup client with mock + client := github.NewClient(tc.mockedClient) + _, handler := ListWorkflows(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.Workflows + err = json.Unmarshal([]byte(textContent.Text), &response) + require.NoError(t, err) + assert.NotNil(t, response.TotalCount) + assert.Greater(t, *response.TotalCount, 0) + assert.NotEmpty(t, response.Workflows) + }) + } +} + +func Test_RunWorkflow(t *testing.T) { + // Verify tool definition once + mockClient := github.NewClient(nil) + tool, _ := RunWorkflow(stubGetClientFn(mockClient), translations.NullTranslationHelper) + + assert.Equal(t, "run_workflow", tool.Name) + assert.NotEmpty(t, tool.Description) + assert.Contains(t, tool.InputSchema.Properties, "owner") + assert.Contains(t, tool.InputSchema.Properties, "repo") + assert.Contains(t, tool.InputSchema.Properties, "workflow_file") + assert.Contains(t, tool.InputSchema.Properties, "ref") + assert.Contains(t, tool.InputSchema.Properties, "inputs") + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "workflow_file", "ref"}) + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]any + expectError bool + expectedErrMsg string + }{ + { + name: "successful workflow run", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.PostReposActionsWorkflowsDispatchesByOwnerByRepoByWorkflowId, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNoContent) + }), + ), + ), + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "workflow_file": "ci.yml", + "ref": "main", + }, + expectError: false, + }, + { + name: "missing required parameter workflow_file", + mockedClient: mock.NewMockedHTTPClient(), + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "ref": "main", + }, + expectError: true, + expectedErrMsg: "missing required parameter: workflow_file", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Setup client with mock + client := github.NewClient(tc.mockedClient) + _, handler := RunWorkflow(stubGetClientFn(client), translations.NullTranslationHelper) + + // Create call request + request := createMCPRequest(tc.requestArgs) + + // Call handler + result, err := handler(context.Background(), request) + + require.NoError(t, err) + require.Equal(t, tc.expectError, result.IsError) + + // Parse the result and get the text content if no error + textContent := getTextResult(t, result) + + if tc.expectedErrMsg != "" { + assert.Equal(t, tc.expectedErrMsg, textContent.Text) + return + } + + // Unmarshal and verify the result + var response map[string]any + err = json.Unmarshal([]byte(textContent.Text), &response) + require.NoError(t, err) + assert.Equal(t, "Workflow run has been queued", response["message"]) + }) + } +} + +func Test_CancelWorkflowRun(t *testing.T) { + // Verify tool definition once + mockClient := github.NewClient(nil) + tool, _ := CancelWorkflowRun(stubGetClientFn(mockClient), translations.NullTranslationHelper) + + assert.Equal(t, "cancel_workflow_run", 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 + requestArgs map[string]any + expectError bool + expectedErrMsg string + }{ + { + name: "successful workflow run cancellation", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatch( + mock.EndpointPattern{ + Pattern: "/repos/owner/repo/actions/runs/12345/cancel", + Method: "POST", + }, + "", // Empty response body for 202 Accepted + ), + ), + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "run_id": float64(12345), + }, + expectError: false, + }, + { + name: "missing required parameter run_id", + mockedClient: mock.NewMockedHTTPClient(), + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + }, + expectError: false, + expectedErrMsg: "missing required parameter: run_id", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Setup client with mock + client := github.NewClient(tc.mockedClient) + _, handler := CancelWorkflowRun(stubGetClientFn(client), translations.NullTranslationHelper) + + // Create call request + request := createMCPRequest(tc.requestArgs) + + // Call handler + result, err := handler(context.Background(), request) + + if tc.expectError { + require.Error(t, err) + if tc.expectedErrMsg != "" { + assert.Contains(t, err.Error(), tc.expectedErrMsg) + } + return + } + + require.NoError(t, err) + + // Parse the result and get the text content + textContent := getTextResult(t, result) + + if tc.expectedErrMsg != "" { + assert.Contains(t, textContent.Text, tc.expectedErrMsg) + return + } + + require.False(t, result.IsError) + + // Unmarshal and verify the result + var response map[string]any + err = json.Unmarshal([]byte(textContent.Text), &response) + require.NoError(t, err) + assert.Equal(t, "Workflow run has been cancelled", response["message"]) + assert.Equal(t, float64(12345), response["run_id"]) + }) + } +} + +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, "per_page") + 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 + requestArgs map[string]any + expectError bool + expectedErrMsg string + }{ + { + name: "successful artifacts listing", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposActionsRunsArtifactsByOwnerByRepoByRunId, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + artifacts := &github.ArtifactList{ + TotalCount: github.Int64(2), + Artifacts: []*github.Artifact{ + { + ID: github.Int64(1), + NodeID: github.String("A_1"), + Name: github.String("build-artifacts"), + SizeInBytes: github.Int64(1024), + URL: github.String("https://api.github.com/repos/owner/repo/actions/artifacts/1"), + ArchiveDownloadURL: github.String("https://api.github.com/repos/owner/repo/actions/artifacts/1/zip"), + Expired: github.Bool(false), + CreatedAt: &github.Timestamp{}, + UpdatedAt: &github.Timestamp{}, + ExpiresAt: &github.Timestamp{}, + WorkflowRun: &github.ArtifactWorkflowRun{ + ID: github.Int64(12345), + RepositoryID: github.Int64(1), + HeadRepositoryID: github.Int64(1), + HeadBranch: github.String("main"), + HeadSHA: github.String("abc123"), + }, + }, + { + ID: github.Int64(2), + NodeID: github.String("A_2"), + Name: github.String("test-results"), + SizeInBytes: github.Int64(512), + URL: github.String("https://api.github.com/repos/owner/repo/actions/artifacts/2"), + ArchiveDownloadURL: github.String("https://api.github.com/repos/owner/repo/actions/artifacts/2/zip"), + Expired: github.Bool(false), + CreatedAt: &github.Timestamp{}, + UpdatedAt: &github.Timestamp{}, + ExpiresAt: &github.Timestamp{}, + WorkflowRun: &github.ArtifactWorkflowRun{ + ID: github.Int64(12345), + RepositoryID: github.Int64(1), + HeadRepositoryID: github.Int64(1), + HeadBranch: github.String("main"), + HeadSHA: github.String("abc123"), + }, + }, + }, + } + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(artifacts) + }), + ), + ), + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "run_id": float64(12345), + }, + expectError: false, + }, + { + name: "missing required parameter run_id", + mockedClient: mock.NewMockedHTTPClient(), + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + }, + expectError: true, + expectedErrMsg: "missing required parameter: run_id", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Setup client with mock + client := github.NewClient(tc.mockedClient) + _, handler := ListWorkflowRunArtifacts(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.ArtifactList + err = json.Unmarshal([]byte(textContent.Text), &response) + require.NoError(t, err) + assert.NotNil(t, response.TotalCount) + assert.Greater(t, *response.TotalCount, int64(0)) + assert.NotEmpty(t, response.Artifacts) + }) + } +} + +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 + requestArgs map[string]any + expectError bool + expectedErrMsg string + }{ + { + name: "successful artifact download URL", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.EndpointPattern{ + Pattern: "/repos/owner/repo/actions/artifacts/123/zip", + Method: "GET", + }, + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // GitHub returns a 302 redirect to the download URL + w.Header().Set("Location", "https://api.github.com/repos/owner/repo/actions/artifacts/123/download") + w.WriteHeader(http.StatusFound) + }), + ), + ), + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "artifact_id": float64(123), + }, + expectError: false, + }, + { + name: "missing required parameter artifact_id", + mockedClient: mock.NewMockedHTTPClient(), + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + }, + expectError: true, + expectedErrMsg: "missing required parameter: artifact_id", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Setup client with mock + client := github.NewClient(tc.mockedClient) + _, handler := DownloadWorkflowRunArtifact(stubGetClientFn(client), translations.NullTranslationHelper) + + // Create call request + request := createMCPRequest(tc.requestArgs) + + // Call handler + result, err := handler(context.Background(), request) + + require.NoError(t, err) + require.Equal(t, tc.expectError, result.IsError) + + // Parse the result and get the text content if no error + textContent := getTextResult(t, result) + + if tc.expectedErrMsg != "" { + assert.Equal(t, tc.expectedErrMsg, textContent.Text) + return + } + + // Unmarshal and verify the result + var response map[string]any + err = json.Unmarshal([]byte(textContent.Text), &response) + require.NoError(t, err) + assert.Contains(t, response, "download_url") + assert.Contains(t, response, "message") + assert.Equal(t, "Artifact is available for download", response["message"]) + assert.Equal(t, float64(123), response["artifact_id"]) + }) + } +} + +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 + requestArgs map[string]any + expectError bool + expectedErrMsg string + }{ + { + name: "successful logs deletion", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.DeleteReposActionsRunsLogsByOwnerByRepoByRunId, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNoContent) + }), + ), + ), + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "run_id": float64(12345), + }, + expectError: false, + }, + { + name: "missing required parameter run_id", + mockedClient: mock.NewMockedHTTPClient(), + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + }, + expectError: true, + expectedErrMsg: "missing required parameter: run_id", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Setup client with mock + client := github.NewClient(tc.mockedClient) + _, handler := DeleteWorkflowRunLogs(stubGetClientFn(client), translations.NullTranslationHelper) + + // Create call request + request := createMCPRequest(tc.requestArgs) + + // Call handler + result, err := handler(context.Background(), request) + + require.NoError(t, err) + require.Equal(t, tc.expectError, result.IsError) + + // Parse the result and get the text content if no error + textContent := getTextResult(t, result) + + if tc.expectedErrMsg != "" { + assert.Equal(t, tc.expectedErrMsg, textContent.Text) + return + } + + // Unmarshal and verify the result + var response map[string]any + err = json.Unmarshal([]byte(textContent.Text), &response) + require.NoError(t, err) + assert.Equal(t, "Workflow run logs have been deleted", response["message"]) + assert.Equal(t, float64(12345), response["run_id"]) + }) + } +} + +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 + requestArgs map[string]any + expectError bool + expectedErrMsg string + }{ + { + name: "successful workflow run usage", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposActionsRunsTimingByOwnerByRepoByRunId, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + usage := &github.WorkflowRunUsage{ + Billable: &github.WorkflowRunBillMap{ + "UBUNTU": &github.WorkflowRunBill{ + TotalMS: github.Int64(120000), + Jobs: github.Int(2), + JobRuns: []*github.WorkflowRunJobRun{ + { + JobID: github.Int(1), + DurationMS: github.Int64(60000), + }, + { + JobID: github.Int(2), + DurationMS: github.Int64(60000), + }, + }, + }, + }, + RunDurationMS: github.Int64(120000), + } + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(usage) + }), + ), + ), + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "run_id": float64(12345), + }, + expectError: false, + }, + { + name: "missing required parameter run_id", + mockedClient: mock.NewMockedHTTPClient(), + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + }, + expectError: true, + expectedErrMsg: "missing required parameter: run_id", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Setup client with mock + client := github.NewClient(tc.mockedClient) + _, handler := GetWorkflowRunUsage(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.WorkflowRunUsage + err = json.Unmarshal([]byte(textContent.Text), &response) + require.NoError(t, err) + assert.NotNil(t, response.RunDurationMS) + assert.NotNil(t, response.Billable) + }) + } +} From a29225de74972742bb803687a1a836708a26ae9f Mon Sep 17 00:00:00 2001 From: gabornyergesX Date: Sun, 8 Jun 2025 16:15:36 +0200 Subject: [PATCH 02/13] feat: enhance GitHub Actions toolset with additional workflow management capabilities - Added new tools for managing GitHub Actions, including listing workflows, retrieving workflow run logs, and managing workflow runs. - Integrated the new `actions` toolset into the default toolset group for improved accessibility. --- pkg/github/actions.go | 13 +++++++++++++ pkg/github/tools.go | 21 +++++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/pkg/github/actions.go b/pkg/github/actions.go index c791212e..246075b7 100644 --- a/pkg/github/actions.go +++ b/pkg/github/actions.go @@ -195,6 +195,9 @@ func ListWorkflowRuns(getClient GetClientFn, t translations.TranslationHelperFun func RunWorkflow(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { return mcp.NewTool("run_workflow", mcp.WithDescription(t("TOOL_RUN_WORKFLOW_DESCRIPTION", "Run an Actions workflow")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + ReadOnlyHint: toBoolPtr(false), + }), mcp.WithString("owner", mcp.Required(), mcp.Description("The account owner of the repository. The name is not case sensitive."), @@ -549,6 +552,9 @@ func GetJobLogs(getClient GetClientFn, t translations.TranslationHelperFunc) (to 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{ + ReadOnlyHint: toBoolPtr(false), + }), mcp.WithString("owner", mcp.Required(), mcp.Description("The account owner of the repository. The name is not case sensitive."), @@ -608,6 +614,9 @@ func RerunWorkflowRun(getClient GetClientFn, t translations.TranslationHelperFun 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{ + ReadOnlyHint: toBoolPtr(false), + }), mcp.WithString("owner", mcp.Required(), mcp.Description("The account owner of the repository. The name is not case sensitive."), @@ -667,6 +676,9 @@ func RerunFailedJobs(getClient GetClientFn, t translations.TranslationHelperFunc 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{ + ReadOnlyHint: toBoolPtr(false), + }), mcp.WithString("owner", mcp.Required(), mcp.Description("The account owner of the repository. The name is not case sensitive."), @@ -868,6 +880,7 @@ func DeleteWorkflowRunLogs(getClient GetClientFn, t translations.TranslationHelp return mcp.NewTool("delete_workflow_run_logs", mcp.WithDescription(t("TOOL_DELETE_WORKFLOW_RUN_LOGS_DESCRIPTION", "Delete logs for a workflow run")), mcp.WithToolAnnotation(mcp.ToolAnnotation{ + ReadOnlyHint: toBoolPtr(false), DestructiveHint: toBoolPtr(true), }), mcp.WithString("owner", diff --git a/pkg/github/tools.go b/pkg/github/tools.go index 9569c439..ba540d22 100644 --- a/pkg/github/tools.go +++ b/pkg/github/tools.go @@ -111,6 +111,26 @@ func DefaultToolsetGroup(readOnly bool, getClient GetClientFn, getGQLClient GetG toolsets.NewServerTool(ManageRepositoryNotificationSubscription(getClient, t)), ) + actions := toolsets.NewToolset("actions", "GitHub Actions workflows and CI/CD operations"). + AddReadTools( + 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(GetJobLogs(getClient, t)), + 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(DeleteWorkflowRunLogs(getClient, t)), + ) + // Keep experiments alive so the system doesn't error out when it's always enabled experiments := toolsets.NewToolset("experiments", "Experimental features that are not considered stable yet") @@ -125,6 +145,7 @@ func DefaultToolsetGroup(readOnly bool, getClient GetClientFn, getGQLClient GetG tsg.AddToolset(issues) tsg.AddToolset(users) tsg.AddToolset(pullRequests) + tsg.AddToolset(actions) tsg.AddToolset(codeSecurity) tsg.AddToolset(secretProtection) tsg.AddToolset(notifications) From 2dc74c8ae75624932825b1edf07fc862a3cd6e25 Mon Sep 17 00:00:00 2001 From: gabornyergesX Date: Mon, 9 Jun 2025 06:37:47 +0200 Subject: [PATCH 03/13] feat: enhance GetJobLogs functionality for improved job log retrieval - Added new tests for GetJobLogs, including scenarios for retrieving logs for both single jobs and failed jobs. - Updated GetJobLogs tool description to clarify its capabilities for fetching logs efficiently. - Implemented error handling for missing required parameters and optimized responses for failed job logs. - Introduced functionality to return actual log content instead of just URLs when requested. --- pkg/github/actions.go | 212 +++++++++++++++++++++--- pkg/github/actions_test.go | 331 +++++++++++++++++++++++++++++++++++-- 2 files changed, 506 insertions(+), 37 deletions(-) diff --git a/pkg/github/actions.go b/pkg/github/actions.go index 246075b7..73283145 100644 --- a/pkg/github/actions.go +++ b/pkg/github/actions.go @@ -4,6 +4,9 @@ import ( "context" "encoding/json" "fmt" + "io" + "net/http" + "strings" "github.com/github/github-mcp-server/pkg/translations" "github.com/google/go-github/v72/github" @@ -336,7 +339,7 @@ func GetWorkflowRun(getClient GetClientFn, t translations.TranslationHelperFunc) // 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", - mcp.WithDescription(t("TOOL_GET_WORKFLOW_RUN_LOGS_DESCRIPTION", "Download logs for a specific workflow run")), + mcp.WithDescription(t("TOOL_GET_WORKFLOW_RUN_LOGS_DESCRIPTION", "Download logs for a specific workflow run (EXPENSIVE: downloads ALL logs as ZIP. Consider using get_job_logs with failed_only=true for debugging failed jobs)")), mcp.WithToolAnnotation(mcp.ToolAnnotation{ ReadOnlyHint: toBoolPtr(true), }), @@ -382,9 +385,11 @@ func GetWorkflowRunLogs(getClient GetClientFn, t translations.TranslationHelperF // Create response with the logs URL and information result := map[string]any{ - "logs_url": url.String(), - "message": "Workflow run logs are available for download", - "note": "The logs_url provides a download link for the complete workflow run logs as a ZIP archive. You can download this archive to extract and examine individual job logs.", + "logs_url": url.String(), + "message": "Workflow run logs are available for download", + "note": "The logs_url provides a download link for the complete workflow run logs as a ZIP archive. You can download this archive to extract and examine individual job logs.", + "warning": "This downloads ALL logs as a ZIP file which can be large and expensive. For debugging failed jobs, consider using get_job_logs with failed_only=true and run_id instead.", + "optimization_tip": "Use: get_job_logs with parameters {run_id: " + fmt.Sprintf("%d", runID) + ", failed_only: true} for more efficient failed job debugging", } r, err := json.Marshal(result) @@ -476,7 +481,13 @@ func ListWorkflowJobs(getClient GetClientFn, t translations.TranslationHelperFun } defer func() { _ = resp.Body.Close() }() - r, err := json.Marshal(jobs) + // 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) } @@ -485,10 +496,10 @@ func ListWorkflowJobs(getClient GetClientFn, t translations.TranslationHelperFun } } -// GetJobLogs creates a tool to download logs for a specific workflow job +// GetJobLogs creates a tool to download logs for a specific workflow job or get failed job logs efficiently func GetJobLogs(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { return mcp.NewTool("get_job_logs", - mcp.WithDescription(t("TOOL_GET_JOB_LOGS_DESCRIPTION", "Download logs for a specific workflow job")), + mcp.WithDescription(t("TOOL_GET_JOB_LOGS_DESCRIPTION", "Download logs for a specific workflow job or efficiently get all failed job logs for a workflow run")), mcp.WithToolAnnotation(mcp.ToolAnnotation{ ReadOnlyHint: toBoolPtr(true), }), @@ -501,8 +512,16 @@ func GetJobLogs(getClient GetClientFn, t translations.TranslationHelperFunc) (to mcp.Description("Repository name"), ), mcp.WithNumber("job_id", - mcp.Required(), - mcp.Description("The unique identifier of the workflow job"), + mcp.Description("The unique identifier of the workflow job (required for single job logs)"), + ), + mcp.WithNumber("run_id", + mcp.Description("Workflow run ID (required when using failed_only)"), + ), + mcp.WithBoolean("failed_only", + mcp.Description("When true, gets logs for all failed jobs in run_id"), + ), + mcp.WithBoolean("return_content", + mcp.Description("Returns actual log content instead of URLs"), ), ), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { @@ -514,38 +533,181 @@ func GetJobLogs(getClient GetClientFn, t translations.TranslationHelperFunc) (to if err != nil { return mcp.NewToolResultError(err.Error()), nil } - jobIDInt, err := RequiredInt(request, "job_id") + + // Get optional parameters + jobID, err := OptionalIntParam(request, "job_id") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + runID, err := OptionalIntParam(request, "run_id") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + failedOnly, err := OptionalParam[bool](request, "failed_only") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + returnContent, err := OptionalParam[bool](request, "return_content") if err != nil { return mcp.NewToolResultError(err.Error()), nil } - jobID := int64(jobIDInt) client, err := getClient(ctx) if err != nil { return nil, fmt.Errorf("failed to get GitHub client: %w", err) } - // Get the download URL for the job logs - url, resp, err := client.Actions.GetWorkflowJobLogs(ctx, owner, repo, jobID, 1) - if err != nil { - return nil, fmt.Errorf("failed to get job logs: %w", err) + // Validate parameters + if failedOnly && runID == 0 { + return mcp.NewToolResultError("run_id is required when failed_only is true"), nil + } + if !failedOnly && jobID == 0 { + return mcp.NewToolResultError("job_id is required when failed_only is false"), nil } - defer func() { _ = resp.Body.Close() }() - // Create response with the logs URL and information - result := map[string]any{ - "logs_url": url.String(), - "message": "Job logs are available for download", - "note": "The logs_url provides a download link for the individual job logs in plain text format. This is more targeted than workflow run logs and easier to read for debugging specific failed steps.", + if failedOnly && runID > 0 { + // Handle failed-only mode: get logs for all failed jobs in the workflow run + return handleFailedJobLogs(ctx, client, owner, repo, int64(runID), returnContent) + } else if jobID > 0 { + // Handle single job mode + return handleSingleJobLogs(ctx, client, owner, repo, int64(jobID), returnContent) } - r, err := json.Marshal(result) - if err != nil { - return nil, fmt.Errorf("failed to marshal response: %w", err) + return mcp.NewToolResultError("Either job_id must be provided for single job logs, or run_id with failed_only=true for failed job logs"), nil + } +} + +// handleFailedJobLogs gets logs for all failed jobs in a workflow run +func handleFailedJobLogs(ctx context.Context, client *github.Client, owner, repo string, runID int64, returnContent bool) (*mcp.CallToolResult, error) { + // First, get all jobs for the workflow run + jobs, resp, err := client.Actions.ListWorkflowJobs(ctx, owner, repo, runID, &github.ListWorkflowJobsOptions{ + Filter: "latest", + }) + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("failed to list workflow jobs: %v", err)), nil + } + defer func() { _ = resp.Body.Close() }() + + // Filter for failed jobs + var failedJobs []*github.WorkflowJob + for _, job := range jobs.Jobs { + if job.GetConclusion() == "failure" { + failedJobs = append(failedJobs, job) + } + } + + if len(failedJobs) == 0 { + result := map[string]any{ + "message": "No failed jobs found in this workflow run", + "run_id": runID, + "total_jobs": len(jobs.Jobs), + "failed_jobs": 0, + } + r, _ := json.Marshal(result) + return mcp.NewToolResultText(string(r)), nil + } + + // Collect logs for all failed jobs + var logResults []map[string]any + for _, job := range failedJobs { + jobResult, err := getJobLogData(ctx, client, owner, repo, job.GetID(), job.GetName(), returnContent) + if err != nil { + // Continue with other jobs even if one fails + jobResult = map[string]any{ + "job_id": job.GetID(), + "job_name": job.GetName(), + "error": err.Error(), } + } + logResults = append(logResults, jobResult) + } + + result := map[string]any{ + "message": fmt.Sprintf("Retrieved logs for %d failed jobs", len(failedJobs)), + "run_id": runID, + "total_jobs": len(jobs.Jobs), + "failed_jobs": len(failedJobs), + "logs": logResults, + "return_format": map[string]bool{"content": returnContent, "urls": !returnContent}, + } + + 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 +// handleSingleJobLogs gets logs for a single job +func handleSingleJobLogs(ctx context.Context, client *github.Client, owner, repo string, jobID int64, returnContent bool) (*mcp.CallToolResult, error) { + jobResult, err := getJobLogData(ctx, client, owner, repo, jobID, "", returnContent) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + r, err := json.Marshal(jobResult) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil +} + +// getJobLogData retrieves log data for a single job, either as URL or content +func getJobLogData(ctx context.Context, client *github.Client, owner, repo string, jobID int64, jobName string, returnContent bool) (map[string]any, error) { + // Get the download URL for the job logs + url, resp, err := client.Actions.GetWorkflowJobLogs(ctx, owner, repo, jobID, 1) + if err != nil { + return nil, fmt.Errorf("failed to get job logs for job %d: %w", jobID, err) + } + defer func() { _ = resp.Body.Close() }() + + result := map[string]any{ + "job_id": jobID, + } + if jobName != "" { + result["job_name"] = jobName + } + + if returnContent { + // Download and return the actual log content + content, err := downloadLogContent(url.String()) + if err != nil { + return nil, fmt.Errorf("failed to download log content for job %d: %w", jobID, err) } + result["logs_content"] = content + result["message"] = "Job logs content retrieved successfully" + } else { + // Return just the URL + result["logs_url"] = url.String() + result["message"] = "Job logs are available for download" + result["note"] = "The logs_url provides a download link for the individual job logs in plain text format. Use return_content=true to get the actual log content." + } + + return result, nil +} + +// downloadLogContent downloads the actual log content from a GitHub logs URL +func downloadLogContent(logURL string) (string, error) { + httpResp, err := http.Get(logURL) + if err != nil { + return "", fmt.Errorf("failed to download logs: %w", err) + } + defer func() { _ = httpResp.Body.Close() }() + + if httpResp.StatusCode != http.StatusOK { + return "", fmt.Errorf("failed to download logs: HTTP %d", httpResp.StatusCode) + } + + content, err := io.ReadAll(httpResp.Body) + if err != nil { + return "", fmt.Errorf("failed to read log content: %w", err) + } + + // Clean up and format the log content for better readability + logContent := strings.TrimSpace(string(content)) + return logContent, nil } // RerunWorkflowRun creates a tool to re-run an entire workflow run diff --git a/pkg/github/actions_test.go b/pkg/github/actions_test.go index f317c622..b81ac36f 100644 --- a/pkg/github/actions_test.go +++ b/pkg/github/actions_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "net/http" + "net/http/httptest" "testing" "github.com/github/github-mcp-server/pkg/translations" @@ -252,7 +253,7 @@ func Test_CancelWorkflowRun(t *testing.T) { "owner": "owner", "repo": "repo", }, - expectError: false, + expectError: true, expectedErrMsg: "missing required parameter: run_id", }, } @@ -269,26 +270,17 @@ func Test_CancelWorkflowRun(t *testing.T) { // Call handler result, err := handler(context.Background(), request) - if tc.expectError { - require.Error(t, err) - if tc.expectedErrMsg != "" { - assert.Contains(t, err.Error(), tc.expectedErrMsg) - } - return - } - require.NoError(t, err) + require.Equal(t, tc.expectError, result.IsError) // Parse the result and get the text content textContent := getTextResult(t, result) if tc.expectedErrMsg != "" { - assert.Contains(t, textContent.Text, tc.expectedErrMsg) + assert.Equal(t, tc.expectedErrMsg, textContent.Text) return } - require.False(t, result.IsError) - // Unmarshal and verify the result var response map[string]any err = json.Unmarshal([]byte(textContent.Text), &response) @@ -696,3 +688,318 @@ func Test_GetWorkflowRunUsage(t *testing.T) { }) } } + +func Test_GetJobLogs(t *testing.T) { + // Verify tool definition once + mockClient := github.NewClient(nil) + tool, _ := GetJobLogs(stubGetClientFn(mockClient), translations.NullTranslationHelper) + + assert.Equal(t, "get_job_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, "job_id") + assert.Contains(t, tool.InputSchema.Properties, "run_id") + assert.Contains(t, tool.InputSchema.Properties, "failed_only") + assert.Contains(t, tool.InputSchema.Properties, "return_content") + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo"}) + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]any + expectError bool + expectedErrMsg string + checkResponse func(t *testing.T, response map[string]any) + }{ + { + name: "successful single job logs with URL", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposActionsJobsLogsByOwnerByRepoByJobId, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Location", "https://github.com/logs/job/123") + w.WriteHeader(http.StatusFound) + }), + ), + ), + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "job_id": float64(123), + }, + expectError: false, + checkResponse: func(t *testing.T, response map[string]any) { + assert.Equal(t, float64(123), response["job_id"]) + assert.Contains(t, response, "logs_url") + assert.Equal(t, "Job logs are available for download", response["message"]) + assert.Contains(t, response, "note") + }, + }, + { + name: "successful failed jobs logs", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposActionsRunsJobsByOwnerByRepoByRunId, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + jobs := &github.Jobs{ + TotalCount: github.Int(3), + Jobs: []*github.WorkflowJob{ + { + ID: github.Int64(1), + Name: github.String("test-job-1"), + Conclusion: github.String("success"), + }, + { + ID: github.Int64(2), + Name: github.String("test-job-2"), + Conclusion: github.String("failure"), + }, + { + ID: github.Int64(3), + Name: github.String("test-job-3"), + Conclusion: github.String("failure"), + }, + }, + } + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(jobs) + }), + ), + mock.WithRequestMatchHandler( + mock.GetReposActionsJobsLogsByOwnerByRepoByJobId, + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Location", "https://github.com/logs/job/"+r.URL.Path[len(r.URL.Path)-1:]) + w.WriteHeader(http.StatusFound) + }), + ), + ), + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "run_id": float64(456), + "failed_only": true, + }, + expectError: false, + checkResponse: func(t *testing.T, response map[string]any) { + assert.Equal(t, float64(456), response["run_id"]) + assert.Equal(t, float64(3), response["total_jobs"]) + assert.Equal(t, float64(2), response["failed_jobs"]) + assert.Contains(t, response, "logs") + assert.Equal(t, "Retrieved logs for 2 failed jobs", response["message"]) + + logs, ok := response["logs"].([]interface{}) + assert.True(t, ok) + assert.Len(t, logs, 2) + }, + }, + { + name: "no failed jobs found", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposActionsRunsJobsByOwnerByRepoByRunId, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + jobs := &github.Jobs{ + TotalCount: github.Int(2), + Jobs: []*github.WorkflowJob{ + { + ID: github.Int64(1), + Name: github.String("test-job-1"), + Conclusion: github.String("success"), + }, + { + ID: github.Int64(2), + Name: github.String("test-job-2"), + Conclusion: github.String("success"), + }, + }, + } + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(jobs) + }), + ), + ), + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "run_id": float64(456), + "failed_only": true, + }, + expectError: false, + checkResponse: func(t *testing.T, response map[string]any) { + assert.Equal(t, "No failed jobs found in this workflow run", response["message"]) + assert.Equal(t, float64(456), response["run_id"]) + assert.Equal(t, float64(2), response["total_jobs"]) + assert.Equal(t, float64(0), response["failed_jobs"]) + }, + }, + { + name: "missing job_id when not using failed_only", + mockedClient: mock.NewMockedHTTPClient(), + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + }, + expectError: true, + expectedErrMsg: "job_id is required when failed_only is false", + }, + { + name: "missing run_id when using failed_only", + mockedClient: mock.NewMockedHTTPClient(), + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "failed_only": true, + }, + expectError: true, + expectedErrMsg: "run_id is required when failed_only is true", + }, + { + name: "missing required parameter owner", + mockedClient: mock.NewMockedHTTPClient(), + requestArgs: map[string]any{ + "repo": "repo", + "job_id": float64(123), + }, + expectError: true, + expectedErrMsg: "missing required parameter: owner", + }, + { + name: "missing required parameter repo", + mockedClient: mock.NewMockedHTTPClient(), + requestArgs: map[string]any{ + "owner": "owner", + "job_id": float64(123), + }, + expectError: true, + expectedErrMsg: "missing required parameter: repo", + }, + { + name: "API error when getting single job logs", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposActionsJobsLogsByOwnerByRepoByJobId, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + _ = json.NewEncoder(w).Encode(map[string]string{ + "message": "Not Found", + }) + }), + ), + ), + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "job_id": float64(999), + }, + expectError: true, + }, + { + name: "API error when listing workflow jobs for failed_only", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposActionsRunsJobsByOwnerByRepoByRunId, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + _ = json.NewEncoder(w).Encode(map[string]string{ + "message": "Not Found", + }) + }), + ), + ), + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "run_id": float64(999), + "failed_only": true, + }, + expectError: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Setup client with mock + client := github.NewClient(tc.mockedClient) + _, handler := GetJobLogs(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 + textContent := getTextResult(t, result) + + if tc.expectedErrMsg != "" { + assert.Equal(t, tc.expectedErrMsg, textContent.Text) + return + } + + if tc.expectError { + // For API errors, just verify we got an error + assert.True(t, result.IsError) + return + } + + // Unmarshal and verify the result + var response map[string]any + err = json.Unmarshal([]byte(textContent.Text), &response) + require.NoError(t, err) + + if tc.checkResponse != nil { + tc.checkResponse(t, response) + } + }) + } +} + +func Test_GetJobLogs_WithContentReturn(t *testing.T) { + // Test the return_content functionality with a mock HTTP server + logContent := "2023-01-01T10:00:00.000Z Starting job...\n2023-01-01T10:00:01.000Z Running tests...\n2023-01-01T10:00:02.000Z Job completed successfully" + + // Create a test server to serve log content + testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(logContent)) + })) + defer testServer.Close() + + mockedClient := mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposActionsJobsLogsByOwnerByRepoByJobId, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Location", testServer.URL) + w.WriteHeader(http.StatusFound) + }), + ), + ) + + client := github.NewClient(mockedClient) + _, handler := GetJobLogs(stubGetClientFn(client), translations.NullTranslationHelper) + + request := createMCPRequest(map[string]any{ + "owner": "owner", + "repo": "repo", + "job_id": float64(123), + "return_content": true, + }) + + result, err := handler(context.Background(), request) + require.NoError(t, err) + require.False(t, result.IsError) + + textContent := getTextResult(t, result) + var response map[string]any + err = json.Unmarshal([]byte(textContent.Text), &response) + require.NoError(t, err) + + assert.Equal(t, float64(123), response["job_id"]) + assert.Equal(t, logContent, response["logs_content"]) + assert.Equal(t, "Job logs content retrieved successfully", response["message"]) + assert.NotContains(t, response, "logs_url") // Should not have URL when returning content +} From 6feed0e0bf341390525d6f119301cadd568ea9e0 Mon Sep 17 00:00:00 2001 From: gabornyergesX Date: Mon, 9 Jun 2025 06:37:47 +0200 Subject: [PATCH 04/13] feat: enhance GetJobLogs functionality for improved job log retrieval - Added new tests for GetJobLogs, including scenarios for retrieving logs for both single jobs and failed jobs. - Updated GetJobLogs tool description to clarify its capabilities for fetching logs efficiently. - Implemented error handling for missing required parameters and optimized responses for failed job logs. - Introduced functionality to return actual log content instead of just URLs when requested. --- README.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 500590ca..5f847e09 100644 --- a/README.md +++ b/README.md @@ -666,11 +666,14 @@ export GITHUB_MCP_TOOL_ADD_ISSUE_COMMENT_DESCRIPTION="an alternative description - `page`: Page number (number, optional) - `perPage`: Results per page (number, optional) -- **get_job_logs** - Download logs for a specific job +- **get_job_logs** - Download logs for a specific workflow job or efficiently get all failed job logs for a workflow run - `owner`: Repository owner (string, required) - `repo`: Repository name (string, required) - - `job_id`: Job ID (number, required) + - `job_id`: Job ID (number, required for single job logs) + - `run_id`: Workflow run ID (number, required when using failed_only) + - `failed_only`: When true, gets logs for all failed jobs in run_id (boolean, optional) + - `return_content`: Returns actual log content instead of URLs (boolean, optional) - **rerun_workflow_run** - Re-run an entire workflow From 3ba73e09a272aa37b874241e75531bfafc69cc6a Mon Sep 17 00:00:00 2001 From: gabornyergesX Date: Tue, 10 Jun 2025 06:31:17 +0200 Subject: [PATCH 05/13] refactor: standardize parameter handling and read-only hints in GitHub Actions tools - Replaced instances of `requiredParam` with `RequiredParam` for consistency across all tools. - Updated `toBoolPtr` to `ToBoolPtr` in tool annotations to maintain uniformity in boolean pointer handling. - Ensured all tools in the GitHub Actions suite adhere to the new naming conventions for improved readability and maintainability. --- pkg/github/actions.go | 92 +++++++++++++++++++++---------------------- 1 file changed, 46 insertions(+), 46 deletions(-) diff --git a/pkg/github/actions.go b/pkg/github/actions.go index 73283145..543b1558 100644 --- a/pkg/github/actions.go +++ b/pkg/github/actions.go @@ -19,7 +19,7 @@ func ListWorkflows(getClient GetClientFn, t translations.TranslationHelperFunc) return mcp.NewTool("list_workflows", mcp.WithDescription(t("TOOL_LIST_WORKFLOWS_DESCRIPTION", "List workflows in a repository")), mcp.WithToolAnnotation(mcp.ToolAnnotation{ - ReadOnlyHint: toBoolPtr(true), + ReadOnlyHint: ToBoolPtr(true), }), mcp.WithString("owner", mcp.Required(), @@ -37,11 +37,11 @@ func ListWorkflows(getClient GetClientFn, t translations.TranslationHelperFunc) ), ), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - owner, err := requiredParam[string](request, "owner") + owner, err := RequiredParam[string](request, "owner") if err != nil { return mcp.NewToolResultError(err.Error()), nil } - repo, err := requiredParam[string](request, "repo") + repo, err := RequiredParam[string](request, "repo") if err != nil { return mcp.NewToolResultError(err.Error()), nil } @@ -87,7 +87,7 @@ func ListWorkflowRuns(getClient GetClientFn, t translations.TranslationHelperFun 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{ - ReadOnlyHint: toBoolPtr(true), + ReadOnlyHint: ToBoolPtr(true), }), mcp.WithString("owner", mcp.Required(), @@ -121,15 +121,15 @@ func ListWorkflowRuns(getClient GetClientFn, t translations.TranslationHelperFun ), ), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - owner, err := requiredParam[string](request, "owner") + owner, err := RequiredParam[string](request, "owner") if err != nil { return mcp.NewToolResultError(err.Error()), nil } - repo, err := requiredParam[string](request, "repo") + repo, err := RequiredParam[string](request, "repo") if err != nil { return mcp.NewToolResultError(err.Error()), nil } - workflowID, err := requiredParam[string](request, "workflow_id") + workflowID, err := RequiredParam[string](request, "workflow_id") if err != nil { return mcp.NewToolResultError(err.Error()), nil } @@ -199,7 +199,7 @@ func RunWorkflow(getClient GetClientFn, t translations.TranslationHelperFunc) (t return mcp.NewTool("run_workflow", mcp.WithDescription(t("TOOL_RUN_WORKFLOW_DESCRIPTION", "Run an Actions workflow")), mcp.WithToolAnnotation(mcp.ToolAnnotation{ - ReadOnlyHint: toBoolPtr(false), + ReadOnlyHint: ToBoolPtr(false), }), mcp.WithString("owner", mcp.Required(), @@ -222,19 +222,19 @@ func RunWorkflow(getClient GetClientFn, t translations.TranslationHelperFunc) (t ), ), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - owner, err := requiredParam[string](request, "owner") + owner, err := RequiredParam[string](request, "owner") if err != nil { return mcp.NewToolResultError(err.Error()), nil } - repo, err := requiredParam[string](request, "repo") + repo, err := RequiredParam[string](request, "repo") if err != nil { return mcp.NewToolResultError(err.Error()), nil } - workflowFile, err := requiredParam[string](request, "workflow_file") + workflowFile, err := RequiredParam[string](request, "workflow_file") if err != nil { return mcp.NewToolResultError(err.Error()), nil } - ref, err := requiredParam[string](request, "ref") + ref, err := RequiredParam[string](request, "ref") if err != nil { return mcp.NewToolResultError(err.Error()), nil } @@ -286,7 +286,7 @@ func GetWorkflowRun(getClient GetClientFn, t translations.TranslationHelperFunc) 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{ - ReadOnlyHint: toBoolPtr(true), + ReadOnlyHint: ToBoolPtr(true), }), mcp.WithString("owner", mcp.Required(), @@ -302,11 +302,11 @@ func GetWorkflowRun(getClient GetClientFn, t translations.TranslationHelperFunc) ), ), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - owner, err := requiredParam[string](request, "owner") + owner, err := RequiredParam[string](request, "owner") if err != nil { return mcp.NewToolResultError(err.Error()), nil } - repo, err := requiredParam[string](request, "repo") + repo, err := RequiredParam[string](request, "repo") if err != nil { return mcp.NewToolResultError(err.Error()), nil } @@ -341,7 +341,7 @@ func GetWorkflowRunLogs(getClient GetClientFn, t translations.TranslationHelperF return mcp.NewTool("get_workflow_run_logs", mcp.WithDescription(t("TOOL_GET_WORKFLOW_RUN_LOGS_DESCRIPTION", "Download logs for a specific workflow run (EXPENSIVE: downloads ALL logs as ZIP. Consider using get_job_logs with failed_only=true for debugging failed jobs)")), mcp.WithToolAnnotation(mcp.ToolAnnotation{ - ReadOnlyHint: toBoolPtr(true), + ReadOnlyHint: ToBoolPtr(true), }), mcp.WithString("owner", mcp.Required(), @@ -357,11 +357,11 @@ func GetWorkflowRunLogs(getClient GetClientFn, t translations.TranslationHelperF ), ), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - owner, err := requiredParam[string](request, "owner") + owner, err := RequiredParam[string](request, "owner") if err != nil { return mcp.NewToolResultError(err.Error()), nil } - repo, err := requiredParam[string](request, "repo") + repo, err := RequiredParam[string](request, "repo") if err != nil { return mcp.NewToolResultError(err.Error()), nil } @@ -406,7 +406,7 @@ func ListWorkflowJobs(getClient GetClientFn, t translations.TranslationHelperFun 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{ - ReadOnlyHint: toBoolPtr(true), + ReadOnlyHint: ToBoolPtr(true), }), mcp.WithString("owner", mcp.Required(), @@ -431,11 +431,11 @@ func ListWorkflowJobs(getClient GetClientFn, t translations.TranslationHelperFun ), ), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - owner, err := requiredParam[string](request, "owner") + owner, err := RequiredParam[string](request, "owner") if err != nil { return mcp.NewToolResultError(err.Error()), nil } - repo, err := requiredParam[string](request, "repo") + repo, err := RequiredParam[string](request, "repo") if err != nil { return mcp.NewToolResultError(err.Error()), nil } @@ -501,7 +501,7 @@ func GetJobLogs(getClient GetClientFn, t translations.TranslationHelperFunc) (to return mcp.NewTool("get_job_logs", mcp.WithDescription(t("TOOL_GET_JOB_LOGS_DESCRIPTION", "Download logs for a specific workflow job or efficiently get all failed job logs for a workflow run")), mcp.WithToolAnnotation(mcp.ToolAnnotation{ - ReadOnlyHint: toBoolPtr(true), + ReadOnlyHint: ToBoolPtr(true), }), mcp.WithString("owner", mcp.Required(), @@ -525,11 +525,11 @@ func GetJobLogs(getClient GetClientFn, t translations.TranslationHelperFunc) (to ), ), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - owner, err := requiredParam[string](request, "owner") + owner, err := RequiredParam[string](request, "owner") if err != nil { return mcp.NewToolResultError(err.Error()), nil } - repo, err := requiredParam[string](request, "repo") + repo, err := RequiredParam[string](request, "repo") if err != nil { return mcp.NewToolResultError(err.Error()), nil } @@ -715,7 +715,7 @@ func RerunWorkflowRun(getClient GetClientFn, t translations.TranslationHelperFun return mcp.NewTool("rerun_workflow_run", mcp.WithDescription(t("TOOL_RERUN_WORKFLOW_RUN_DESCRIPTION", "Re-run an entire workflow run")), mcp.WithToolAnnotation(mcp.ToolAnnotation{ - ReadOnlyHint: toBoolPtr(false), + ReadOnlyHint: ToBoolPtr(false), }), mcp.WithString("owner", mcp.Required(), @@ -731,11 +731,11 @@ func RerunWorkflowRun(getClient GetClientFn, t translations.TranslationHelperFun ), ), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - owner, err := requiredParam[string](request, "owner") + owner, err := RequiredParam[string](request, "owner") if err != nil { return mcp.NewToolResultError(err.Error()), nil } - repo, err := requiredParam[string](request, "repo") + repo, err := RequiredParam[string](request, "repo") if err != nil { return mcp.NewToolResultError(err.Error()), nil } @@ -777,7 +777,7 @@ func RerunFailedJobs(getClient GetClientFn, t translations.TranslationHelperFunc 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{ - ReadOnlyHint: toBoolPtr(false), + ReadOnlyHint: ToBoolPtr(false), }), mcp.WithString("owner", mcp.Required(), @@ -793,11 +793,11 @@ func RerunFailedJobs(getClient GetClientFn, t translations.TranslationHelperFunc ), ), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - owner, err := requiredParam[string](request, "owner") + owner, err := RequiredParam[string](request, "owner") if err != nil { return mcp.NewToolResultError(err.Error()), nil } - repo, err := requiredParam[string](request, "repo") + repo, err := RequiredParam[string](request, "repo") if err != nil { return mcp.NewToolResultError(err.Error()), nil } @@ -839,7 +839,7 @@ func CancelWorkflowRun(getClient GetClientFn, t translations.TranslationHelperFu return mcp.NewTool("cancel_workflow_run", mcp.WithDescription(t("TOOL_CANCEL_WORKFLOW_RUN_DESCRIPTION", "Cancel a workflow run")), mcp.WithToolAnnotation(mcp.ToolAnnotation{ - ReadOnlyHint: toBoolPtr(false), + ReadOnlyHint: ToBoolPtr(false), }), mcp.WithString("owner", mcp.Required(), @@ -855,11 +855,11 @@ func CancelWorkflowRun(getClient GetClientFn, t translations.TranslationHelperFu ), ), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - owner, err := requiredParam[string](request, "owner") + owner, err := RequiredParam[string](request, "owner") if err != nil { return mcp.NewToolResultError(err.Error()), nil } - repo, err := requiredParam[string](request, "repo") + repo, err := RequiredParam[string](request, "repo") if err != nil { return mcp.NewToolResultError(err.Error()), nil } @@ -901,7 +901,7 @@ func ListWorkflowRunArtifacts(getClient GetClientFn, t translations.TranslationH 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{ - ReadOnlyHint: toBoolPtr(true), + ReadOnlyHint: ToBoolPtr(true), }), mcp.WithString("owner", mcp.Required(), @@ -923,11 +923,11 @@ func ListWorkflowRunArtifacts(getClient GetClientFn, t translations.TranslationH ), ), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - owner, err := requiredParam[string](request, "owner") + owner, err := RequiredParam[string](request, "owner") if err != nil { return mcp.NewToolResultError(err.Error()), nil } - repo, err := requiredParam[string](request, "repo") + repo, err := RequiredParam[string](request, "repo") if err != nil { return mcp.NewToolResultError(err.Error()), nil } @@ -978,7 +978,7 @@ func DownloadWorkflowRunArtifact(getClient GetClientFn, t translations.Translati 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{ - ReadOnlyHint: toBoolPtr(true), + ReadOnlyHint: ToBoolPtr(true), }), mcp.WithString("owner", mcp.Required(), @@ -994,11 +994,11 @@ func DownloadWorkflowRunArtifact(getClient GetClientFn, t translations.Translati ), ), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - owner, err := requiredParam[string](request, "owner") + owner, err := RequiredParam[string](request, "owner") if err != nil { return mcp.NewToolResultError(err.Error()), nil } - repo, err := requiredParam[string](request, "repo") + repo, err := RequiredParam[string](request, "repo") if err != nil { return mcp.NewToolResultError(err.Error()), nil } @@ -1042,8 +1042,8 @@ func DeleteWorkflowRunLogs(getClient GetClientFn, t translations.TranslationHelp return mcp.NewTool("delete_workflow_run_logs", mcp.WithDescription(t("TOOL_DELETE_WORKFLOW_RUN_LOGS_DESCRIPTION", "Delete logs for a workflow run")), mcp.WithToolAnnotation(mcp.ToolAnnotation{ - ReadOnlyHint: toBoolPtr(false), - DestructiveHint: toBoolPtr(true), + ReadOnlyHint: ToBoolPtr(false), + DestructiveHint: ToBoolPtr(true), }), mcp.WithString("owner", mcp.Required(), @@ -1059,11 +1059,11 @@ func DeleteWorkflowRunLogs(getClient GetClientFn, t translations.TranslationHelp ), ), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - owner, err := requiredParam[string](request, "owner") + owner, err := RequiredParam[string](request, "owner") if err != nil { return mcp.NewToolResultError(err.Error()), nil } - repo, err := requiredParam[string](request, "repo") + repo, err := RequiredParam[string](request, "repo") if err != nil { return mcp.NewToolResultError(err.Error()), nil } @@ -1105,7 +1105,7 @@ func GetWorkflowRunUsage(getClient GetClientFn, t translations.TranslationHelper 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{ - ReadOnlyHint: toBoolPtr(true), + ReadOnlyHint: ToBoolPtr(true), }), mcp.WithString("owner", mcp.Required(), @@ -1121,11 +1121,11 @@ func GetWorkflowRunUsage(getClient GetClientFn, t translations.TranslationHelper ), ), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - owner, err := requiredParam[string](request, "owner") + owner, err := RequiredParam[string](request, "owner") if err != nil { return mcp.NewToolResultError(err.Error()), nil } - repo, err := requiredParam[string](request, "repo") + repo, err := RequiredParam[string](request, "repo") if err != nil { return mcp.NewToolResultError(err.Error()), nil } From 0701251c3c2e4a0f35973c7b7409f3cff05debb5 Mon Sep 17 00:00:00 2001 From: gabornyergesX Date: Fri, 13 Jun 2025 11:30:51 +0200 Subject: [PATCH 06/13] docs: add missing actions toolset to Available Toolsets table --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 5f847e09..58e49fbe 100644 --- a/README.md +++ b/README.md @@ -192,6 +192,7 @@ The following sets of tools are available (all are on by default): | Toolset | Description | | ----------------------- | ------------------------------------------------------------- | +| `actions` | GitHub Actions workflows and CI/CD operations | | `context` | **Strongly recommended**: Tools that provide context about the current user and GitHub context you are operating in | | `code_security` | Code scanning alerts and security features | | `issues` | Issue-related tools (create, read, update, comment) | From 9e283fee27cb6eaf6dc71bf0a0f322eadf6c8f42 Mon Sep 17 00:00:00 2001 From: gabornyergesX Date: Mon, 16 Jun 2025 19:47:37 +0200 Subject: [PATCH 07/13] feat: enhance GitHub Actions tool descriptions with enumerated options - Updated descriptions for workflow run status and job filters to include enumerated options for clarity. - Improved documentation for better usability and understanding of available parameters. --- pkg/github/actions.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/pkg/github/actions.go b/pkg/github/actions.go index 543b1558..f42a7f30 100644 --- a/pkg/github/actions.go +++ b/pkg/github/actions.go @@ -111,7 +111,8 @@ func ListWorkflowRuns(getClient GetClientFn, t translations.TranslationHelperFun mcp.Description("Returns workflow runs for an event. For example, push, pull_request, or issue."), ), mcp.WithString("status", - mcp.Description("Returns workflow runs with the check run status. For example, completed, in_progress, or requested."), + mcp.Description("Returns workflow runs with the check run status"), + mcp.Enum("queued", "in_progress", "completed", "requested", "waiting"), ), mcp.WithNumber("per_page", mcp.Description("The number of results per page (max 100)"), @@ -421,7 +422,8 @@ func ListWorkflowJobs(getClient GetClientFn, t translations.TranslationHelperFun mcp.Description("The unique identifier of the workflow run"), ), mcp.WithString("filter", - mcp.Description("Filters jobs by their completed_at timestamp. Can be one of: latest, all"), + mcp.Description("Filters jobs by their completed_at timestamp"), + mcp.Enum("latest", "all"), ), mcp.WithNumber("per_page", mcp.Description("The number of results per page (max 100)"), From 13392497646dd148995640d9b401e1404048bcde Mon Sep 17 00:00:00 2001 From: gabornyergesX Date: Tue, 17 Jun 2025 05:14:49 +0200 Subject: [PATCH 08/13] feat: expand event type options in GitHub Actions tool descriptions - Enhanced the event parameter description in the ListWorkflowRuns function to include a comprehensive list of supported event types. - Improved clarity and usability for users by providing enumerated options for event types in the documentation. --- pkg/github/actions.go | 36 +++++++++++++++++++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/pkg/github/actions.go b/pkg/github/actions.go index f42a7f30..34736406 100644 --- a/pkg/github/actions.go +++ b/pkg/github/actions.go @@ -108,7 +108,41 @@ func ListWorkflowRuns(getClient GetClientFn, t translations.TranslationHelperFun mcp.Description("Returns workflow runs associated with a branch. Use the name of the branch."), ), mcp.WithString("event", - mcp.Description("Returns workflow runs for an event. For example, push, pull_request, or issue."), + 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"), From deb66ce6b678bc5e175cc2d1bfcee5495b5dcbe7 Mon Sep 17 00:00:00 2001 From: gabornyergesX Date: Tue, 17 Jun 2025 05:43:57 +0200 Subject: [PATCH 09/13] feat: add support for running workflows by ID and filename in GitHub Actions tools - Introduced a new tool, RunWorkflowByFileName, to allow users to run workflows using the workflow filename. - Updated the existing RunWorkflow tool to accept a numeric workflow ID instead of a filename. - Enhanced tests to cover scenarios for both running workflows by ID and filename, including error handling for missing parameters. - Improved tool descriptions for clarity and usability. --- pkg/github/actions.go | 106 ++++++++++++++++++++++++++++++++++--- pkg/github/actions_test.go | 88 +++++++++++++++++++++++++++++- pkg/github/tools.go | 1 + 3 files changed, 185 insertions(+), 10 deletions(-) diff --git a/pkg/github/actions.go b/pkg/github/actions.go index 34736406..03616b51 100644 --- a/pkg/github/actions.go +++ b/pkg/github/actions.go @@ -229,24 +229,24 @@ func ListWorkflowRuns(getClient GetClientFn, t translations.TranslationHelperFun } } -// RunWorkflow creates a tool to run an Actions workflow +// RunWorkflow creates a tool to run an Actions workflow by workflow ID func RunWorkflow(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { return mcp.NewTool("run_workflow", - mcp.WithDescription(t("TOOL_RUN_WORKFLOW_DESCRIPTION", "Run an Actions workflow")), + mcp.WithDescription(t("TOOL_RUN_WORKFLOW_DESCRIPTION", "Run an Actions workflow by workflow ID")), mcp.WithToolAnnotation(mcp.ToolAnnotation{ ReadOnlyHint: ToBoolPtr(false), }), mcp.WithString("owner", mcp.Required(), - mcp.Description("The account owner of the repository. The name is not case sensitive."), + mcp.Description("Repository owner"), ), mcp.WithString("repo", mcp.Required(), mcp.Description("Repository name"), ), - mcp.WithString("workflow_file", + mcp.WithNumber("workflow_id", mcp.Required(), - mcp.Description("The workflow ID or workflow file name"), + mcp.Description("The workflow ID (numeric identifier)"), ), mcp.WithString("ref", mcp.Required(), @@ -265,10 +265,11 @@ func RunWorkflow(getClient GetClientFn, t translations.TranslationHelperFunc) (t if err != nil { return mcp.NewToolResultError(err.Error()), nil } - workflowFile, err := RequiredParam[string](request, "workflow_file") + workflowIDInt, err := RequiredInt(request, "workflow_id") if err != nil { return mcp.NewToolResultError(err.Error()), nil } + workflowID := int64(workflowIDInt) ref, err := RequiredParam[string](request, "ref") if err != nil { return mcp.NewToolResultError(err.Error()), nil @@ -292,7 +293,9 @@ func RunWorkflow(getClient GetClientFn, t translations.TranslationHelperFunc) (t Inputs: inputs, } - resp, err := client.Actions.CreateWorkflowDispatchEventByFileName(ctx, owner, repo, workflowFile, event) + // Convert workflow ID to string format for the API call + workflowIDStr := fmt.Sprintf("%d", workflowID) + resp, err := client.Actions.CreateWorkflowDispatchEventByFileName(ctx, owner, repo, workflowIDStr, event) if err != nil { return nil, fmt.Errorf("failed to run workflow: %w", err) } @@ -300,7 +303,7 @@ func RunWorkflow(getClient GetClientFn, t translations.TranslationHelperFunc) (t result := map[string]any{ "message": "Workflow run has been queued", - "workflow": workflowFile, + "workflow_id": workflowID, "ref": ref, "inputs": inputs, "status": resp.Status, @@ -316,6 +319,93 @@ func RunWorkflow(getClient GetClientFn, t translations.TranslationHelperFunc) (t } } +// RunWorkflowByFileName creates a tool to run an Actions workflow by filename +func RunWorkflowByFileName(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("run_workflow_by_filename", + mcp.WithDescription(t("TOOL_RUN_WORKFLOW_BY_FILENAME_DESCRIPTION", "Run an Actions workflow by workflow filename")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + ReadOnlyHint: ToBoolPtr(false), + }), + mcp.WithString("owner", + mcp.Required(), + mcp.Description("Repository owner"), + ), + mcp.WithString("repo", + mcp.Required(), + mcp.Description("Repository name"), + ), + mcp.WithString("workflow_file", + mcp.Required(), + mcp.Description("The workflow file name (e.g., main.yml, ci.yaml)"), + ), + mcp.WithString("ref", + mcp.Required(), + mcp.Description("The git reference for the workflow. The reference can be a branch or tag name."), + ), + mcp.WithObject("inputs", + mcp.Description("Inputs the workflow accepts"), + ), + ), + 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 + } + workflowFile, err := RequiredParam[string](request, "workflow_file") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + ref, err := RequiredParam[string](request, "ref") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + // Get optional inputs parameter + var inputs map[string]interface{} + if requestInputs, ok := request.GetArguments()["inputs"]; ok { + if inputsMap, ok := requestInputs.(map[string]interface{}); ok { + inputs = inputsMap + } + } + + client, err := getClient(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + + event := github.CreateWorkflowDispatchEventRequest{ + Ref: ref, + Inputs: inputs, + } + + resp, err := client.Actions.CreateWorkflowDispatchEventByFileName(ctx, owner, repo, workflowFile, event) + if err != nil { + return nil, fmt.Errorf("failed to run workflow: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + result := map[string]any{ + "message": "Workflow run has been queued", + "workflow_file": workflowFile, + "ref": ref, + "inputs": inputs, + "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) + } + + return mcp.NewToolResultText(string(r)), nil + } +} + // 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", diff --git a/pkg/github/actions_test.go b/pkg/github/actions_test.go index b81ac36f..6e6324af 100644 --- a/pkg/github/actions_test.go +++ b/pkg/github/actions_test.go @@ -134,6 +134,90 @@ func Test_RunWorkflow(t *testing.T) { assert.NotEmpty(t, tool.Description) assert.Contains(t, tool.InputSchema.Properties, "owner") assert.Contains(t, tool.InputSchema.Properties, "repo") + assert.Contains(t, tool.InputSchema.Properties, "workflow_id") + assert.Contains(t, tool.InputSchema.Properties, "ref") + assert.Contains(t, tool.InputSchema.Properties, "inputs") + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "workflow_id", "ref"}) + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]any + expectError bool + expectedErrMsg string + }{ + { + name: "successful workflow run", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.PostReposActionsWorkflowsDispatchesByOwnerByRepoByWorkflowId, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNoContent) + }), + ), + ), + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "workflow_id": float64(12345), + "ref": "main", + }, + expectError: false, + }, + { + name: "missing required parameter workflow_id", + mockedClient: mock.NewMockedHTTPClient(), + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "ref": "main", + }, + expectError: true, + expectedErrMsg: "missing required parameter: workflow_id", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Setup client with mock + client := github.NewClient(tc.mockedClient) + _, handler := RunWorkflow(stubGetClientFn(client), translations.NullTranslationHelper) + + // Create call request + request := createMCPRequest(tc.requestArgs) + + // Call handler + result, err := handler(context.Background(), request) + + require.NoError(t, err) + require.Equal(t, tc.expectError, result.IsError) + + // Parse the result and get the text content if no error + textContent := getTextResult(t, result) + + if tc.expectedErrMsg != "" { + assert.Equal(t, tc.expectedErrMsg, textContent.Text) + return + } + + // Unmarshal and verify the result + var response map[string]any + err = json.Unmarshal([]byte(textContent.Text), &response) + require.NoError(t, err) + assert.Equal(t, "Workflow run has been queued", response["message"]) + }) + } +} + +func Test_RunWorkflowByFileName(t *testing.T) { + // Verify tool definition once + mockClient := github.NewClient(nil) + tool, _ := RunWorkflowByFileName(stubGetClientFn(mockClient), translations.NullTranslationHelper) + + assert.Equal(t, "run_workflow_by_filename", tool.Name) + assert.NotEmpty(t, tool.Description) + assert.Contains(t, tool.InputSchema.Properties, "owner") + assert.Contains(t, tool.InputSchema.Properties, "repo") assert.Contains(t, tool.InputSchema.Properties, "workflow_file") assert.Contains(t, tool.InputSchema.Properties, "ref") assert.Contains(t, tool.InputSchema.Properties, "inputs") @@ -147,7 +231,7 @@ func Test_RunWorkflow(t *testing.T) { expectedErrMsg string }{ { - name: "successful workflow run", + name: "successful workflow run by filename", mockedClient: mock.NewMockedHTTPClient( mock.WithRequestMatchHandler( mock.PostReposActionsWorkflowsDispatchesByOwnerByRepoByWorkflowId, @@ -181,7 +265,7 @@ func Test_RunWorkflow(t *testing.T) { t.Run(tc.name, func(t *testing.T) { // Setup client with mock client := github.NewClient(tc.mockedClient) - _, handler := RunWorkflow(stubGetClientFn(client), translations.NullTranslationHelper) + _, handler := RunWorkflowByFileName(stubGetClientFn(client), translations.NullTranslationHelper) // Create call request request := createMCPRequest(tc.requestArgs) diff --git a/pkg/github/tools.go b/pkg/github/tools.go index ba540d22..1034a77d 100644 --- a/pkg/github/tools.go +++ b/pkg/github/tools.go @@ -125,6 +125,7 @@ func DefaultToolsetGroup(readOnly bool, getClient GetClientFn, getGQLClient GetG ). AddWriteTools( toolsets.NewServerTool(RunWorkflow(getClient, t)), + toolsets.NewServerTool(RunWorkflowByFileName(getClient, t)), toolsets.NewServerTool(RerunWorkflowRun(getClient, t)), toolsets.NewServerTool(RerunFailedJobs(getClient, t)), toolsets.NewServerTool(CancelWorkflowRun(getClient, t)), From d8285fe76419a4939c190ca0c4fb4b81e3272bf6 Mon Sep 17 00:00:00 2001 From: gabornyergesX Date: Tue, 17 Jun 2025 05:55:55 +0200 Subject: [PATCH 10/13] feat: standardize repository parameter descriptions in GitHub Actions tools - Introduced constants for repository owner and name descriptions to enhance consistency across multiple tools. - Updated all relevant tools to use the new constants for improved clarity and maintainability in parameter descriptions. --- pkg/github/actions.go | 65 +++++++++++++++++++++++-------------------- 1 file changed, 35 insertions(+), 30 deletions(-) diff --git a/pkg/github/actions.go b/pkg/github/actions.go index 03616b51..f7a93cf8 100644 --- a/pkg/github/actions.go +++ b/pkg/github/actions.go @@ -14,6 +14,11 @@ import ( "github.com/mark3labs/mcp-go/server" ) +const ( + DescriptionRepositoryOwner = "Repository owner" + DescriptionRepositoryName = "Repository name" +) + // 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", @@ -23,11 +28,11 @@ func ListWorkflows(getClient GetClientFn, t translations.TranslationHelperFunc) }), mcp.WithString("owner", mcp.Required(), - mcp.Description("The account owner of the repository. The name is not case sensitive."), + mcp.Description(DescriptionRepositoryOwner), ), mcp.WithString("repo", mcp.Required(), - mcp.Description("Repository name"), + mcp.Description(DescriptionRepositoryName), ), mcp.WithNumber("per_page", mcp.Description("The number of results per page (max 100)"), @@ -91,11 +96,11 @@ func ListWorkflowRuns(getClient GetClientFn, t translations.TranslationHelperFun }), mcp.WithString("owner", mcp.Required(), - mcp.Description("The account owner of the repository. The name is not case sensitive."), + mcp.Description(DescriptionRepositoryOwner), ), mcp.WithString("repo", mcp.Required(), - mcp.Description("Repository name"), + mcp.Description(DescriptionRepositoryName), ), mcp.WithString("workflow_id", mcp.Required(), @@ -238,11 +243,11 @@ func RunWorkflow(getClient GetClientFn, t translations.TranslationHelperFunc) (t }), mcp.WithString("owner", mcp.Required(), - mcp.Description("Repository owner"), + mcp.Description(DescriptionRepositoryOwner), ), mcp.WithString("repo", mcp.Required(), - mcp.Description("Repository name"), + mcp.Description(DescriptionRepositoryName), ), mcp.WithNumber("workflow_id", mcp.Required(), @@ -328,11 +333,11 @@ func RunWorkflowByFileName(getClient GetClientFn, t translations.TranslationHelp }), mcp.WithString("owner", mcp.Required(), - mcp.Description("Repository owner"), + mcp.Description(DescriptionRepositoryOwner), ), mcp.WithString("repo", mcp.Required(), - mcp.Description("Repository name"), + mcp.Description(DescriptionRepositoryName), ), mcp.WithString("workflow_file", mcp.Required(), @@ -415,11 +420,11 @@ func GetWorkflowRun(getClient GetClientFn, t translations.TranslationHelperFunc) }), mcp.WithString("owner", mcp.Required(), - mcp.Description("The account owner of the repository. The name is not case sensitive."), + mcp.Description(DescriptionRepositoryOwner), ), mcp.WithString("repo", mcp.Required(), - mcp.Description("Repository name"), + mcp.Description(DescriptionRepositoryName), ), mcp.WithNumber("run_id", mcp.Required(), @@ -470,11 +475,11 @@ func GetWorkflowRunLogs(getClient GetClientFn, t translations.TranslationHelperF }), mcp.WithString("owner", mcp.Required(), - mcp.Description("The account owner of the repository. The name is not case sensitive."), + mcp.Description(DescriptionRepositoryOwner), ), mcp.WithString("repo", mcp.Required(), - mcp.Description("Repository name"), + mcp.Description(DescriptionRepositoryName), ), mcp.WithNumber("run_id", mcp.Required(), @@ -535,11 +540,11 @@ func ListWorkflowJobs(getClient GetClientFn, t translations.TranslationHelperFun }), mcp.WithString("owner", mcp.Required(), - mcp.Description("The account owner of the repository. The name is not case sensitive."), + mcp.Description(DescriptionRepositoryOwner), ), mcp.WithString("repo", mcp.Required(), - mcp.Description("Repository name"), + mcp.Description(DescriptionRepositoryName), ), mcp.WithNumber("run_id", mcp.Required(), @@ -631,11 +636,11 @@ func GetJobLogs(getClient GetClientFn, t translations.TranslationHelperFunc) (to }), mcp.WithString("owner", mcp.Required(), - mcp.Description("The account owner of the repository. The name is not case sensitive."), + mcp.Description(DescriptionRepositoryOwner), ), mcp.WithString("repo", mcp.Required(), - mcp.Description("Repository name"), + mcp.Description(DescriptionRepositoryName), ), mcp.WithNumber("job_id", mcp.Description("The unique identifier of the workflow job (required for single job logs)"), @@ -845,11 +850,11 @@ func RerunWorkflowRun(getClient GetClientFn, t translations.TranslationHelperFun }), mcp.WithString("owner", mcp.Required(), - mcp.Description("The account owner of the repository. The name is not case sensitive."), + mcp.Description(DescriptionRepositoryOwner), ), mcp.WithString("repo", mcp.Required(), - mcp.Description("Repository name"), + mcp.Description(DescriptionRepositoryName), ), mcp.WithNumber("run_id", mcp.Required(), @@ -907,11 +912,11 @@ func RerunFailedJobs(getClient GetClientFn, t translations.TranslationHelperFunc }), mcp.WithString("owner", mcp.Required(), - mcp.Description("The account owner of the repository. The name is not case sensitive."), + mcp.Description(DescriptionRepositoryOwner), ), mcp.WithString("repo", mcp.Required(), - mcp.Description("Repository name"), + mcp.Description(DescriptionRepositoryName), ), mcp.WithNumber("run_id", mcp.Required(), @@ -969,11 +974,11 @@ func CancelWorkflowRun(getClient GetClientFn, t translations.TranslationHelperFu }), mcp.WithString("owner", mcp.Required(), - mcp.Description("The account owner of the repository. The name is not case sensitive."), + mcp.Description(DescriptionRepositoryOwner), ), mcp.WithString("repo", mcp.Required(), - mcp.Description("Repository name"), + mcp.Description(DescriptionRepositoryName), ), mcp.WithNumber("run_id", mcp.Required(), @@ -1031,11 +1036,11 @@ func ListWorkflowRunArtifacts(getClient GetClientFn, t translations.TranslationH }), mcp.WithString("owner", mcp.Required(), - mcp.Description("The account owner of the repository. The name is not case sensitive."), + mcp.Description(DescriptionRepositoryOwner), ), mcp.WithString("repo", mcp.Required(), - mcp.Description("Repository name"), + mcp.Description(DescriptionRepositoryName), ), mcp.WithNumber("run_id", mcp.Required(), @@ -1108,11 +1113,11 @@ func DownloadWorkflowRunArtifact(getClient GetClientFn, t translations.Translati }), mcp.WithString("owner", mcp.Required(), - mcp.Description("The account owner of the repository. The name is not case sensitive."), + mcp.Description(DescriptionRepositoryOwner), ), mcp.WithString("repo", mcp.Required(), - mcp.Description("Repository name"), + mcp.Description(DescriptionRepositoryName), ), mcp.WithNumber("artifact_id", mcp.Required(), @@ -1173,11 +1178,11 @@ func DeleteWorkflowRunLogs(getClient GetClientFn, t translations.TranslationHelp }), mcp.WithString("owner", mcp.Required(), - mcp.Description("The account owner of the repository. The name is not case sensitive."), + mcp.Description(DescriptionRepositoryOwner), ), mcp.WithString("repo", mcp.Required(), - mcp.Description("Repository name"), + mcp.Description(DescriptionRepositoryName), ), mcp.WithNumber("run_id", mcp.Required(), @@ -1235,11 +1240,11 @@ func GetWorkflowRunUsage(getClient GetClientFn, t translations.TranslationHelper }), mcp.WithString("owner", mcp.Required(), - mcp.Description("The account owner of the repository. The name is not case sensitive."), + mcp.Description(DescriptionRepositoryOwner), ), mcp.WithString("repo", mcp.Required(), - mcp.Description("Repository name"), + mcp.Description(DescriptionRepositoryName), ), mcp.WithNumber("run_id", mcp.Required(), From f7e1320af7f64a7ee6440d07ac0359eaa484b892 Mon Sep 17 00:00:00 2001 From: gabornyergesX Date: Tue, 17 Jun 2025 06:21:11 +0200 Subject: [PATCH 11/13] feat: enhance GitHub Actions tools with user-friendly titles - Added user-friendly titles to tool annotations for various GitHub Actions tools, improving clarity and usability for end-users. - Updated descriptions for tools including ListWorkflows, ListWorkflowRuns, RunWorkflow, and others to include new titles for better identification and understanding of their functionalities. --- pkg/github/actions.go | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/pkg/github/actions.go b/pkg/github/actions.go index f7a93cf8..95371cab 100644 --- a/pkg/github/actions.go +++ b/pkg/github/actions.go @@ -24,6 +24,7 @@ func ListWorkflows(getClient GetClientFn, t translations.TranslationHelperFunc) 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", @@ -92,6 +93,7 @@ func ListWorkflowRuns(getClient GetClientFn, t translations.TranslationHelperFun 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", @@ -239,6 +241,7 @@ func RunWorkflow(getClient GetClientFn, t translations.TranslationHelperFunc) (t return mcp.NewTool("run_workflow", mcp.WithDescription(t("TOOL_RUN_WORKFLOW_DESCRIPTION", "Run an Actions workflow by workflow ID")), mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_RUN_WORKFLOW_USER_TITLE", "Run workflow"), ReadOnlyHint: ToBoolPtr(false), }), mcp.WithString("owner", @@ -329,6 +332,7 @@ func RunWorkflowByFileName(getClient GetClientFn, t translations.TranslationHelp return mcp.NewTool("run_workflow_by_filename", mcp.WithDescription(t("TOOL_RUN_WORKFLOW_BY_FILENAME_DESCRIPTION", "Run an Actions workflow by workflow filename")), mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_RUN_WORKFLOW_BY_FILENAME_USER_TITLE", "Run workflow by filename"), ReadOnlyHint: ToBoolPtr(false), }), mcp.WithString("owner", @@ -416,6 +420,7 @@ func GetWorkflowRun(getClient GetClientFn, t translations.TranslationHelperFunc) 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", @@ -471,6 +476,7 @@ func GetWorkflowRunLogs(getClient GetClientFn, t translations.TranslationHelperF return mcp.NewTool("get_workflow_run_logs", mcp.WithDescription(t("TOOL_GET_WORKFLOW_RUN_LOGS_DESCRIPTION", "Download logs for a specific workflow run (EXPENSIVE: downloads ALL logs as ZIP. Consider using get_job_logs with failed_only=true for debugging failed jobs)")), mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_GET_WORKFLOW_RUN_LOGS_USER_TITLE", "Get workflow run logs"), ReadOnlyHint: ToBoolPtr(true), }), mcp.WithString("owner", @@ -536,6 +542,7 @@ func ListWorkflowJobs(getClient GetClientFn, t translations.TranslationHelperFun 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", @@ -627,11 +634,12 @@ func ListWorkflowJobs(getClient GetClientFn, t translations.TranslationHelperFun } } -// GetJobLogs creates a tool to download logs for a specific workflow job or get failed job logs efficiently +// 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) (tool mcp.Tool, handler server.ToolHandlerFunc) { return mcp.NewTool("get_job_logs", mcp.WithDescription(t("TOOL_GET_JOB_LOGS_DESCRIPTION", "Download logs for a specific workflow job or efficiently get all failed job logs for a workflow run")), mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_GET_JOB_LOGS_USER_TITLE", "Get job logs"), ReadOnlyHint: ToBoolPtr(true), }), mcp.WithString("owner", @@ -846,6 +854,7 @@ func RerunWorkflowRun(getClient GetClientFn, t translations.TranslationHelperFun 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", @@ -908,6 +917,7 @@ func RerunFailedJobs(getClient GetClientFn, t translations.TranslationHelperFunc 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", @@ -970,6 +980,7 @@ func CancelWorkflowRun(getClient GetClientFn, t translations.TranslationHelperFu 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", @@ -1032,6 +1043,7 @@ func ListWorkflowRunArtifacts(getClient GetClientFn, t translations.TranslationH 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", @@ -1109,6 +1121,7 @@ func DownloadWorkflowRunArtifact(getClient GetClientFn, t translations.Translati 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", @@ -1173,6 +1186,7 @@ func DeleteWorkflowRunLogs(getClient GetClientFn, t translations.TranslationHelp return mcp.NewTool("delete_workflow_run_logs", mcp.WithDescription(t("TOOL_DELETE_WORKFLOW_RUN_LOGS_DESCRIPTION", "Delete logs for a workflow run")), mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_DELETE_WORKFLOW_RUN_LOGS_USER_TITLE", "Delete workflow logs"), ReadOnlyHint: ToBoolPtr(false), DestructiveHint: ToBoolPtr(true), }), @@ -1236,6 +1250,7 @@ func GetWorkflowRunUsage(getClient GetClientFn, t translations.TranslationHelper 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", From f06c5494157056c4717248a43e4f7762c871192b Mon Sep 17 00:00:00 2001 From: gabornyergesX Date: Tue, 17 Jun 2025 15:29:04 +0200 Subject: [PATCH 12/13] feat: unify workflow execution in GitHub Actions tools - Refactored the RunWorkflow tool to accept both numeric workflow IDs and filenames, enhancing flexibility for users. - Updated the corresponding tests to reflect changes in parameter handling and added assertions for workflow type in responses. - Removed the separate RunWorkflowByFileName tool to streamline functionality and improve code maintainability. --- pkg/github/actions.go | 111 ++++++------------------------------- pkg/github/actions_test.go | 52 +++++++++-------- pkg/github/tools.go | 1 - 3 files changed, 46 insertions(+), 118 deletions(-) diff --git a/pkg/github/actions.go b/pkg/github/actions.go index 95371cab..7903f517 100644 --- a/pkg/github/actions.go +++ b/pkg/github/actions.go @@ -6,6 +6,7 @@ import ( "fmt" "io" "net/http" + "strconv" "strings" "github.com/github/github-mcp-server/pkg/translations" @@ -236,10 +237,10 @@ func ListWorkflowRuns(getClient GetClientFn, t translations.TranslationHelperFun } } -// RunWorkflow creates a tool to run an Actions workflow by workflow ID +// RunWorkflow creates a tool to run an Actions workflow func RunWorkflow(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { return mcp.NewTool("run_workflow", - mcp.WithDescription(t("TOOL_RUN_WORKFLOW_DESCRIPTION", "Run an Actions workflow by workflow ID")), + mcp.WithDescription(t("TOOL_RUN_WORKFLOW_DESCRIPTION", "Run an Actions workflow by workflow ID or filename")), mcp.WithToolAnnotation(mcp.ToolAnnotation{ Title: t("TOOL_RUN_WORKFLOW_USER_TITLE", "Run workflow"), ReadOnlyHint: ToBoolPtr(false), @@ -252,9 +253,9 @@ func RunWorkflow(getClient GetClientFn, t translations.TranslationHelperFunc) (t mcp.Required(), mcp.Description(DescriptionRepositoryName), ), - mcp.WithNumber("workflow_id", + mcp.WithString("workflow_id", mcp.Required(), - mcp.Description("The workflow ID (numeric identifier)"), + mcp.Description("The workflow ID (numeric) or workflow file name (e.g., main.yml, ci.yaml)"), ), mcp.WithString("ref", mcp.Required(), @@ -273,11 +274,10 @@ func RunWorkflow(getClient GetClientFn, t translations.TranslationHelperFunc) (t if err != nil { return mcp.NewToolResultError(err.Error()), nil } - workflowIDInt, err := RequiredInt(request, "workflow_id") + workflowID, err := RequiredParam[string](request, "workflow_id") if err != nil { return mcp.NewToolResultError(err.Error()), nil } - workflowID := int64(workflowIDInt) ref, err := RequiredParam[string](request, "ref") if err != nil { return mcp.NewToolResultError(err.Error()), nil @@ -301,97 +301,17 @@ func RunWorkflow(getClient GetClientFn, t translations.TranslationHelperFunc) (t Inputs: inputs, } - // Convert workflow ID to string format for the API call - workflowIDStr := fmt.Sprintf("%d", workflowID) - resp, err := client.Actions.CreateWorkflowDispatchEventByFileName(ctx, owner, repo, workflowIDStr, event) - if err != nil { - return nil, fmt.Errorf("failed to run workflow: %w", err) - } - defer func() { _ = resp.Body.Close() }() - - result := map[string]any{ - "message": "Workflow run has been queued", - "workflow_id": workflowID, - "ref": ref, - "inputs": inputs, - "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) - } - - return mcp.NewToolResultText(string(r)), nil - } -} - -// RunWorkflowByFileName creates a tool to run an Actions workflow by filename -func RunWorkflowByFileName(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { - return mcp.NewTool("run_workflow_by_filename", - mcp.WithDescription(t("TOOL_RUN_WORKFLOW_BY_FILENAME_DESCRIPTION", "Run an Actions workflow by workflow filename")), - mcp.WithToolAnnotation(mcp.ToolAnnotation{ - Title: t("TOOL_RUN_WORKFLOW_BY_FILENAME_USER_TITLE", "Run workflow by filename"), - ReadOnlyHint: ToBoolPtr(false), - }), - mcp.WithString("owner", - mcp.Required(), - mcp.Description(DescriptionRepositoryOwner), - ), - mcp.WithString("repo", - mcp.Required(), - mcp.Description(DescriptionRepositoryName), - ), - mcp.WithString("workflow_file", - mcp.Required(), - mcp.Description("The workflow file name (e.g., main.yml, ci.yaml)"), - ), - mcp.WithString("ref", - mcp.Required(), - mcp.Description("The git reference for the workflow. The reference can be a branch or tag name."), - ), - mcp.WithObject("inputs", - mcp.Description("Inputs the workflow accepts"), - ), - ), - 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 - } - workflowFile, err := RequiredParam[string](request, "workflow_file") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - ref, err := RequiredParam[string](request, "ref") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - - // Get optional inputs parameter - var inputs map[string]interface{} - if requestInputs, ok := request.GetArguments()["inputs"]; ok { - if inputsMap, ok := requestInputs.(map[string]interface{}); ok { - inputs = inputsMap - } - } - - client, err := getClient(ctx) - if err != nil { - return nil, fmt.Errorf("failed to get GitHub client: %w", err) - } + var resp *github.Response + var workflowType string - event := github.CreateWorkflowDispatchEventRequest{ - Ref: ref, - Inputs: inputs, + if workflowIDInt, parseErr := strconv.ParseInt(workflowID, 10, 64); parseErr == nil { + resp, err = client.Actions.CreateWorkflowDispatchEventByID(ctx, owner, repo, workflowIDInt, event) + workflowType = "workflow_id" + } else { + resp, err = client.Actions.CreateWorkflowDispatchEventByFileName(ctx, owner, repo, workflowID, event) + workflowType = "workflow_file" } - resp, err := client.Actions.CreateWorkflowDispatchEventByFileName(ctx, owner, repo, workflowFile, event) if err != nil { return nil, fmt.Errorf("failed to run workflow: %w", err) } @@ -399,7 +319,8 @@ func RunWorkflowByFileName(getClient GetClientFn, t translations.TranslationHelp result := map[string]any{ "message": "Workflow run has been queued", - "workflow_file": workflowFile, + "workflow_type": workflowType, + "workflow_id": workflowID, "ref": ref, "inputs": inputs, "status": resp.Status, diff --git a/pkg/github/actions_test.go b/pkg/github/actions_test.go index 6e6324af..92b93471 100644 --- a/pkg/github/actions_test.go +++ b/pkg/github/actions_test.go @@ -159,7 +159,7 @@ func Test_RunWorkflow(t *testing.T) { requestArgs: map[string]any{ "owner": "owner", "repo": "repo", - "workflow_id": float64(12345), + "workflow_id": "12345", "ref": "main", }, expectError: false, @@ -205,24 +205,13 @@ func Test_RunWorkflow(t *testing.T) { err = json.Unmarshal([]byte(textContent.Text), &response) require.NoError(t, err) assert.Equal(t, "Workflow run has been queued", response["message"]) + assert.Contains(t, response, "workflow_type") }) } } -func Test_RunWorkflowByFileName(t *testing.T) { - // Verify tool definition once - mockClient := github.NewClient(nil) - tool, _ := RunWorkflowByFileName(stubGetClientFn(mockClient), translations.NullTranslationHelper) - - assert.Equal(t, "run_workflow_by_filename", tool.Name) - assert.NotEmpty(t, tool.Description) - assert.Contains(t, tool.InputSchema.Properties, "owner") - assert.Contains(t, tool.InputSchema.Properties, "repo") - assert.Contains(t, tool.InputSchema.Properties, "workflow_file") - assert.Contains(t, tool.InputSchema.Properties, "ref") - assert.Contains(t, tool.InputSchema.Properties, "inputs") - assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "workflow_file", "ref"}) - +func Test_RunWorkflow_WithFilename(t *testing.T) { + // Test the unified RunWorkflow function with filenames tests := []struct { name string mockedClient *http.Client @@ -241,15 +230,33 @@ func Test_RunWorkflowByFileName(t *testing.T) { ), ), requestArgs: map[string]any{ - "owner": "owner", - "repo": "repo", - "workflow_file": "ci.yml", - "ref": "main", + "owner": "owner", + "repo": "repo", + "workflow_id": "ci.yml", + "ref": "main", }, expectError: false, }, { - name: "missing required parameter workflow_file", + name: "successful workflow run by numeric ID as string", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.PostReposActionsWorkflowsDispatchesByOwnerByRepoByWorkflowId, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNoContent) + }), + ), + ), + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "workflow_id": "12345", + "ref": "main", + }, + expectError: false, + }, + { + name: "missing required parameter workflow_id", mockedClient: mock.NewMockedHTTPClient(), requestArgs: map[string]any{ "owner": "owner", @@ -257,7 +264,7 @@ func Test_RunWorkflowByFileName(t *testing.T) { "ref": "main", }, expectError: true, - expectedErrMsg: "missing required parameter: workflow_file", + expectedErrMsg: "missing required parameter: workflow_id", }, } @@ -265,7 +272,7 @@ func Test_RunWorkflowByFileName(t *testing.T) { t.Run(tc.name, func(t *testing.T) { // Setup client with mock client := github.NewClient(tc.mockedClient) - _, handler := RunWorkflowByFileName(stubGetClientFn(client), translations.NullTranslationHelper) + _, handler := RunWorkflow(stubGetClientFn(client), translations.NullTranslationHelper) // Create call request request := createMCPRequest(tc.requestArgs) @@ -289,6 +296,7 @@ func Test_RunWorkflowByFileName(t *testing.T) { err = json.Unmarshal([]byte(textContent.Text), &response) require.NoError(t, err) assert.Equal(t, "Workflow run has been queued", response["message"]) + assert.Contains(t, response, "workflow_type") }) } } diff --git a/pkg/github/tools.go b/pkg/github/tools.go index 1034a77d..ba540d22 100644 --- a/pkg/github/tools.go +++ b/pkg/github/tools.go @@ -125,7 +125,6 @@ func DefaultToolsetGroup(readOnly bool, getClient GetClientFn, getGQLClient GetG ). AddWriteTools( toolsets.NewServerTool(RunWorkflow(getClient, t)), - toolsets.NewServerTool(RunWorkflowByFileName(getClient, t)), toolsets.NewServerTool(RerunWorkflowRun(getClient, t)), toolsets.NewServerTool(RerunFailedJobs(getClient, t)), toolsets.NewServerTool(CancelWorkflowRun(getClient, t)), From 1365e8cce09277bf9a9341053ffe14e329635c5e Mon Sep 17 00:00:00 2001 From: Gabor Nyerges Date: Wed, 18 Jun 2025 14:47:32 +0200 Subject: [PATCH 13/13] fix: linting issues --- pkg/github/actions.go | 2 +- pkg/github/actions_test.go | 132 ++++++++++++++++++------------------- 2 files changed, 67 insertions(+), 67 deletions(-) diff --git a/pkg/github/actions.go b/pkg/github/actions.go index 7903f517..527a426e 100644 --- a/pkg/github/actions.go +++ b/pkg/github/actions.go @@ -750,7 +750,7 @@ func getJobLogData(ctx context.Context, client *github.Client, owner, repo strin // downloadLogContent downloads the actual log content from a GitHub logs URL func downloadLogContent(logURL string) (string, error) { - httpResp, err := http.Get(logURL) + httpResp, err := http.Get(logURL) //nolint:gosec // URLs are provided by GitHub API and are safe if err != nil { return "", fmt.Errorf("failed to download logs: %w", err) } diff --git a/pkg/github/actions_test.go b/pkg/github/actions_test.go index 92b93471..388c0bbe 100644 --- a/pkg/github/actions_test.go +++ b/pkg/github/actions_test.go @@ -41,31 +41,31 @@ func Test_ListWorkflows(t *testing.T) { mock.GetReposActionsWorkflowsByOwnerByRepo, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { workflows := &github.Workflows{ - TotalCount: github.Int(2), + TotalCount: github.Ptr(2), Workflows: []*github.Workflow{ { - ID: github.Int64(123), - Name: github.String("CI"), - Path: github.String(".github/workflows/ci.yml"), - State: github.String("active"), + ID: github.Ptr(int64(123)), + Name: github.Ptr("CI"), + Path: github.Ptr(".github/workflows/ci.yml"), + State: github.Ptr("active"), CreatedAt: &github.Timestamp{}, UpdatedAt: &github.Timestamp{}, - URL: github.String("https://api.github.com/repos/owner/repo/actions/workflows/123"), - HTMLURL: github.String("https://github.com/owner/repo/actions/workflows/ci.yml"), - BadgeURL: github.String("https://github.com/owner/repo/workflows/CI/badge.svg"), - NodeID: github.String("W_123"), + URL: github.Ptr("https://api.github.com/repos/owner/repo/actions/workflows/123"), + HTMLURL: github.Ptr("https://github.com/owner/repo/actions/workflows/ci.yml"), + BadgeURL: github.Ptr("https://github.com/owner/repo/workflows/CI/badge.svg"), + NodeID: github.Ptr("W_123"), }, { - ID: github.Int64(456), - Name: github.String("Deploy"), - Path: github.String(".github/workflows/deploy.yml"), - State: github.String("active"), + ID: github.Ptr(int64(456)), + Name: github.Ptr("Deploy"), + Path: github.Ptr(".github/workflows/deploy.yml"), + State: github.Ptr("active"), CreatedAt: &github.Timestamp{}, UpdatedAt: &github.Timestamp{}, - URL: github.String("https://api.github.com/repos/owner/repo/actions/workflows/456"), - HTMLURL: github.String("https://github.com/owner/repo/actions/workflows/deploy.yml"), - BadgeURL: github.String("https://github.com/owner/repo/workflows/Deploy/badge.svg"), - NodeID: github.String("W_456"), + URL: github.Ptr("https://api.github.com/repos/owner/repo/actions/workflows/456"), + HTMLURL: github.Ptr("https://github.com/owner/repo/actions/workflows/deploy.yml"), + BadgeURL: github.Ptr("https://github.com/owner/repo/workflows/Deploy/badge.svg"), + NodeID: github.Ptr("W_456"), }, }, } @@ -411,44 +411,44 @@ func Test_ListWorkflowRunArtifacts(t *testing.T) { mock.GetReposActionsRunsArtifactsByOwnerByRepoByRunId, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { artifacts := &github.ArtifactList{ - TotalCount: github.Int64(2), + TotalCount: github.Ptr(int64(2)), Artifacts: []*github.Artifact{ { - ID: github.Int64(1), - NodeID: github.String("A_1"), - Name: github.String("build-artifacts"), - SizeInBytes: github.Int64(1024), - URL: github.String("https://api.github.com/repos/owner/repo/actions/artifacts/1"), - ArchiveDownloadURL: github.String("https://api.github.com/repos/owner/repo/actions/artifacts/1/zip"), - Expired: github.Bool(false), + ID: github.Ptr(int64(1)), + NodeID: github.Ptr("A_1"), + Name: github.Ptr("build-artifacts"), + SizeInBytes: github.Ptr(int64(1024)), + URL: github.Ptr("https://api.github.com/repos/owner/repo/actions/artifacts/1"), + ArchiveDownloadURL: github.Ptr("https://api.github.com/repos/owner/repo/actions/artifacts/1/zip"), + Expired: github.Ptr(false), CreatedAt: &github.Timestamp{}, UpdatedAt: &github.Timestamp{}, ExpiresAt: &github.Timestamp{}, WorkflowRun: &github.ArtifactWorkflowRun{ - ID: github.Int64(12345), - RepositoryID: github.Int64(1), - HeadRepositoryID: github.Int64(1), - HeadBranch: github.String("main"), - HeadSHA: github.String("abc123"), + ID: github.Ptr(int64(12345)), + RepositoryID: github.Ptr(int64(1)), + HeadRepositoryID: github.Ptr(int64(1)), + HeadBranch: github.Ptr("main"), + HeadSHA: github.Ptr("abc123"), }, }, { - ID: github.Int64(2), - NodeID: github.String("A_2"), - Name: github.String("test-results"), - SizeInBytes: github.Int64(512), - URL: github.String("https://api.github.com/repos/owner/repo/actions/artifacts/2"), - ArchiveDownloadURL: github.String("https://api.github.com/repos/owner/repo/actions/artifacts/2/zip"), - Expired: github.Bool(false), + ID: github.Ptr(int64(2)), + NodeID: github.Ptr("A_2"), + Name: github.Ptr("test-results"), + SizeInBytes: github.Ptr(int64(512)), + URL: github.Ptr("https://api.github.com/repos/owner/repo/actions/artifacts/2"), + ArchiveDownloadURL: github.Ptr("https://api.github.com/repos/owner/repo/actions/artifacts/2/zip"), + Expired: github.Ptr(false), CreatedAt: &github.Timestamp{}, UpdatedAt: &github.Timestamp{}, ExpiresAt: &github.Timestamp{}, WorkflowRun: &github.ArtifactWorkflowRun{ - ID: github.Int64(12345), - RepositoryID: github.Int64(1), - HeadRepositoryID: github.Int64(1), - HeadBranch: github.String("main"), - HeadSHA: github.String("abc123"), + ID: github.Ptr(int64(12345)), + RepositoryID: github.Ptr(int64(1)), + HeadRepositoryID: github.Ptr(int64(1)), + HeadBranch: github.Ptr("main"), + HeadSHA: github.Ptr("abc123"), }, }, }, @@ -708,21 +708,21 @@ func Test_GetWorkflowRunUsage(t *testing.T) { usage := &github.WorkflowRunUsage{ Billable: &github.WorkflowRunBillMap{ "UBUNTU": &github.WorkflowRunBill{ - TotalMS: github.Int64(120000), - Jobs: github.Int(2), + TotalMS: github.Ptr(int64(120000)), + Jobs: github.Ptr(2), JobRuns: []*github.WorkflowRunJobRun{ { - JobID: github.Int(1), - DurationMS: github.Int64(60000), + JobID: github.Ptr(1), + DurationMS: github.Ptr(int64(60000)), }, { - JobID: github.Int(2), - DurationMS: github.Int64(60000), + JobID: github.Ptr(2), + DurationMS: github.Ptr(int64(60000)), }, }, }, }, - RunDurationMS: github.Int64(120000), + RunDurationMS: github.Ptr(int64(120000)), } w.WriteHeader(http.StatusOK) _ = json.NewEncoder(w).Encode(usage) @@ -835,22 +835,22 @@ func Test_GetJobLogs(t *testing.T) { mock.GetReposActionsRunsJobsByOwnerByRepoByRunId, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { jobs := &github.Jobs{ - TotalCount: github.Int(3), + TotalCount: github.Ptr(3), Jobs: []*github.WorkflowJob{ { - ID: github.Int64(1), - Name: github.String("test-job-1"), - Conclusion: github.String("success"), + ID: github.Ptr(int64(1)), + Name: github.Ptr("test-job-1"), + Conclusion: github.Ptr("success"), }, { - ID: github.Int64(2), - Name: github.String("test-job-2"), - Conclusion: github.String("failure"), + ID: github.Ptr(int64(2)), + Name: github.Ptr("test-job-2"), + Conclusion: github.Ptr("failure"), }, { - ID: github.Int64(3), - Name: github.String("test-job-3"), - Conclusion: github.String("failure"), + ID: github.Ptr(int64(3)), + Name: github.Ptr("test-job-3"), + Conclusion: github.Ptr("failure"), }, }, } @@ -892,17 +892,17 @@ func Test_GetJobLogs(t *testing.T) { mock.GetReposActionsRunsJobsByOwnerByRepoByRunId, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { jobs := &github.Jobs{ - TotalCount: github.Int(2), + TotalCount: github.Ptr(2), Jobs: []*github.WorkflowJob{ { - ID: github.Int64(1), - Name: github.String("test-job-1"), - Conclusion: github.String("success"), + ID: github.Ptr(int64(1)), + Name: github.Ptr("test-job-1"), + Conclusion: github.Ptr("success"), }, { - ID: github.Int64(2), - Name: github.String("test-job-2"), - Conclusion: github.String("success"), + ID: github.Ptr(int64(2)), + Name: github.Ptr("test-job-2"), + Conclusion: github.Ptr("success"), }, }, }