diff --git a/README.md b/README.md index 5f9baa780e..4eca892ea5 100644 --- a/README.md +++ b/README.md @@ -362,9 +362,35 @@ For a complete overview of all installation options, see our **[Installation Gui > **Note:** Any host application that supports local MCP servers should be able to access the local GitHub MCP server. However, the specific configuration process, syntax and stability of the integration will vary by host application. While many may follow a similar format to the examples above, this is not guaranteed. Please refer to your host application's documentation for the correct MCP configuration syntax and setup process. +### Install with Go + +If you have Go installed, you can install the server with a single command: + +```bash +go install github.com/github/github-mcp-server/cmd/github-mcp-server@latest +``` + +Then configure your MCP client to use the local binary: + +```JSON +{ + "mcp": { + "servers": { + "github": { + "command": "github-mcp-server", + "args": ["stdio"], + "env": { + "GITHUB_PERSONAL_ACCESS_TOKEN": "" + } + } + } + } +} +``` + ### Build from source -If you don't have Docker, you can use `go build` to build the binary in the +If you don't have Docker or Go, you can use `go build` to build the binary in the `cmd/github-mcp-server` directory, and use the `github-mcp-server stdio` command with the `GITHUB_PERSONAL_ACCESS_TOKEN` environment variable set to your token. To specify the output location of the build, use the `-o` flag. You should configure your server to use the built executable as its `command`. For example: ```JSON diff --git a/pkg/errors/error.go b/pkg/errors/error.go index d757651592..ce3bc6bcf8 100644 --- a/pkg/errors/error.go +++ b/pkg/errors/error.go @@ -2,8 +2,10 @@ package errors import ( "context" + "errors" "fmt" "net/http" + "time" "github.com/github/github-mcp-server/pkg/utils" "github.com/google/go-github/v82/github" @@ -159,6 +161,25 @@ func NewGitHubAPIErrorResponse(ctx context.Context, message string, resp *github if ctx != nil { _, _ = addGitHubAPIErrorToContext(ctx, apiErr) // Explicitly ignore error for graceful handling } + + // Handle rate limit errors with clear, actionable messages + var rateLimitErr *github.RateLimitError + if errors.As(err, &rateLimitErr) { + retryIn := time.Until(rateLimitErr.Rate.Reset.Time).Round(time.Second) + return utils.NewToolResultError(fmt.Sprintf( + "%s: GitHub API rate limit exceeded. Retry after %v.", message, retryIn)) + } + + var abuseErr *github.AbuseRateLimitError + if errors.As(err, &abuseErr) { + if abuseErr.RetryAfter != nil { + return utils.NewToolResultError(fmt.Sprintf( + "%s: GitHub secondary rate limit exceeded. Retry after %v.", message, abuseErr.RetryAfter.Round(time.Second))) + } + return utils.NewToolResultError(fmt.Sprintf( + "%s: GitHub secondary rate limit exceeded. Wait before retrying.", message)) + } + return utils.NewToolResultErrorFromErr(message, err) } diff --git a/pkg/github/actions.go b/pkg/github/actions.go index c3b5bb8c71..aafefb6ca5 100644 --- a/pkg/github/actions.go +++ b/pkg/github/actions.go @@ -125,6 +125,61 @@ func handleSingleJobLogs(ctx context.Context, client *github.Client, owner, repo return utils.NewToolResultText(string(r)), nil, nil } +// handleAllJobLogs gets logs for all jobs in a workflow run +func handleAllJobLogs(ctx context.Context, client *github.Client, owner, repo string, runID int64, returnContent bool, tailLines int, contentWindowSize int) (*mcp.CallToolResult, any, 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 ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to list workflow jobs", resp, err), nil, nil + } + defer func() { _ = resp.Body.Close() }() + + if len(jobs.Jobs) == 0 { + result := map[string]any{ + "message": "No jobs found in this workflow run", + "run_id": runID, + "total_jobs": 0, + } + r, _ := json.Marshal(result) + return utils.NewToolResultText(string(r)), nil, nil + } + + // Collect logs for all jobs + var logResults []map[string]any + for _, job := range jobs.Jobs { + jobResult, resp, err := getJobLogData(ctx, client, owner, repo, job.GetID(), job.GetName(), returnContent, tailLines, contentWindowSize) + 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(), + } + // Enable reporting of status codes and error causes + _, _ = ghErrors.NewGitHubAPIErrorToCtx(ctx, "failed to get job logs", resp, err) // Explicitly ignore error for graceful handling + } + + logResults = append(logResults, jobResult) + } + + result := map[string]any{ + "message": fmt.Sprintf("Retrieved logs for %d jobs", len(jobs.Jobs)), + "run_id": runID, + "total_jobs": len(jobs.Jobs), + "logs": logResults, + "return_format": map[string]bool{"content": returnContent, "urls": !returnContent}, + } + + r, err := json.Marshal(result) + if err != nil { + return nil, nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return utils.NewToolResultText(string(r)), nil, 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, tailLines int, contentWindowSize int) (map[string]any, *github.Response, error) { // Get the download URL for the job logs @@ -716,19 +771,20 @@ For single job logs, provide job_id. For all failed jobs in a run, provide run_i if failedOnly && runID == 0 { return utils.NewToolResultError("run_id is required when failed_only is true"), nil, nil } - if !failedOnly && jobID == 0 { - return utils.NewToolResultError("job_id is required when failed_only is false"), nil, nil - } - 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, tailLines, deps.GetContentWindowSize()) + if runID > 0 { + if failedOnly { + // Handle failed-only mode: get logs for all failed jobs in the workflow run + return handleFailedJobLogs(ctx, client, owner, repo, int64(runID), returnContent, tailLines, deps.GetContentWindowSize()) + } + // Handle all jobs mode: get logs for all jobs in the workflow run + return handleAllJobLogs(ctx, client, owner, repo, int64(runID), returnContent, tailLines, deps.GetContentWindowSize()) } else if jobID > 0 { // Handle single job mode return handleSingleJobLogs(ctx, client, owner, repo, int64(jobID), returnContent, tailLines, deps.GetContentWindowSize()) } - return utils.NewToolResultError("Either job_id must be provided for single job logs, or run_id with failed_only=true for failed job logs"), nil, nil + return utils.NewToolResultError("Either job_id or run_id must be provided"), nil, nil }, ) return tool diff --git a/pkg/github/search.go b/pkg/github/search.go index d5ddb4a72a..d3b954b715 100644 --- a/pkg/github/search.go +++ b/pkg/github/search.go @@ -173,7 +173,7 @@ func SearchCode(t translations.TranslationHelperFunc) inventory.ServerTool { Properties: map[string]*jsonschema.Schema{ "query": { Type: "string", - Description: "Search query using GitHub's powerful code search syntax. Examples: 'content:Skill language:Java org:github', 'NOT is:archived language:Python OR language:go', 'repo:github/github-mcp-server'. Supports exact matching, language filters, path filters, and more.", + Description: "Search query using GitHub code search syntax. Qualifiers: repo:owner/repo, org:name, language:Go, path:src/*.js, path:/regex/, symbol:Name, content:term, is:archived|fork. Boolean: AND, OR, NOT, parentheses. Regex: surround with slashes e.g. /sparse.*index/. Glob in path: path:*.ts, path:/src/**/*.js. Examples: 'MyFunc language:go repo:owner/repo', 'symbol:WithContext language:go', '/GetAttributes|SetAttributes/ repo:owner/repo', '(Foo OR Bar) path:src repo:owner/repo'.", }, "sort": { Type: "string",