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 003164e0c..500590cac 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 000000000..c791212eb --- /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 000000000..f317c6222 --- /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 c791212eb..246075b7b 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 9569c4390..ba540d227 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 246075b7b..732831455 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 f317c6222..b81ac36fe 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 500590cac..5f847e09c 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 732831455..543b15585 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 5f847e09c..58e49fbed 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 543b15585..f42a7f30c 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 f42a7f30c..347364061 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 347364061..03616b51d 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 b81ac36fe..6e6324af1 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 ba540d227..1034a77da 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 03616b51d..f7a93cf86 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 f7a93cf86..95371cabc 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 95371cabc..7903f517f 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 6e6324af1..92b93471c 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 1034a77da..ba540d227 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 7903f517f..527a426ed 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 92b93471c..388c0bbe2 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"), }, }, } pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy