package github import ( "context" "encoding/json" "fmt" "io" "net/http" "github.com/go-viper/mapstructure/v2" "github.com/google/go-github/v79/github" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/shurcooL/githubv4" ghErrors "github.com/github/github-mcp-server/pkg/errors" "github.com/github/github-mcp-server/pkg/lockdown" "github.com/github/github-mcp-server/pkg/sanitize" "github.com/github/github-mcp-server/pkg/translations" "github.com/github/github-mcp-server/pkg/utils" ) // PullRequestRead creates a tool to get details of a specific pull request. func PullRequestRead(getClient GetClientFn, cache *lockdown.RepoAccessCache, t translations.TranslationHelperFunc, flags FeatureFlags) (mcp.Tool, mcp.ToolHandlerFor[map[string]any, any]) { schema := &jsonschema.Schema{ Type: "object", Properties: map[string]*jsonschema.Schema{ "method": { Type: "string", Description: `Action to specify what pull request data needs to be retrieved from GitHub. Possible options: 1. get - Get details of a specific pull request. 2. get_diff - Get the diff of a pull request. 3. get_status - Get status of a head commit in a pull request. This reflects status of builds and checks. 4. get_files - Get the list of files changed in a pull request. Use with pagination parameters to control the number of results returned. 5. get_review_comments - Get the review comments on a pull request. They are comments made on a portion of the unified diff during a pull request review. Use with pagination parameters to control the number of results returned. 6. get_reviews - Get the reviews on a pull request. When asked for review comments, use get_review_comments method. 7. get_comments - Get comments on a pull request. Use this if user doesn't specifically want review comments. Use with pagination parameters to control the number of results returned. `, Enum: []any{"get", "get_diff", "get_status", "get_files", "get_review_comments", "get_reviews", "get_comments"}, }, "owner": { Type: "string", Description: "Repository owner", }, "repo": { Type: "string", Description: "Repository name", }, "pullNumber": { Type: "number", Description: "Pull request number", }, }, Required: []string{"method", "owner", "repo", "pullNumber"}, } WithPagination(schema) return mcp.Tool{ Name: "pull_request_read", Description: t("TOOL_PULL_REQUEST_READ_DESCRIPTION", "Get information on a specific pull request in GitHub repository."), Annotations: &mcp.ToolAnnotations{ Title: t("TOOL_GET_PULL_REQUEST_USER_TITLE", "Get details for a single pull request"), ReadOnlyHint: true, }, InputSchema: schema, }, func(ctx context.Context, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { method, err := RequiredParam[string](args, "method") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } owner, err := RequiredParam[string](args, "owner") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } repo, err := RequiredParam[string](args, "repo") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } pullNumber, err := RequiredInt(args, "pullNumber") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } pagination, err := OptionalPaginationParams(args) if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } client, err := getClient(ctx) if err != nil { return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil } switch method { case "get": result, err := GetPullRequest(ctx, client, cache, owner, repo, pullNumber, flags) return result, nil, err case "get_diff": result, err := GetPullRequestDiff(ctx, client, owner, repo, pullNumber) return result, nil, err case "get_status": result, err := GetPullRequestStatus(ctx, client, owner, repo, pullNumber) return result, nil, err case "get_files": result, err := GetPullRequestFiles(ctx, client, owner, repo, pullNumber, pagination) return result, nil, err case "get_review_comments": result, err := GetPullRequestReviewComments(ctx, client, cache, owner, repo, pullNumber, pagination, flags) return result, nil, err case "get_reviews": result, err := GetPullRequestReviews(ctx, client, cache, owner, repo, pullNumber, flags) return result, nil, err case "get_comments": result, err := GetIssueComments(ctx, client, cache, owner, repo, pullNumber, pagination, flags) return result, nil, err default: return utils.NewToolResultError(fmt.Sprintf("unknown method: %s", method)), nil, nil } } } func GetPullRequest(ctx context.Context, client *github.Client, cache *lockdown.RepoAccessCache, owner, repo string, pullNumber int, ff FeatureFlags) (*mcp.CallToolResult, error) { pr, resp, err := client.PullRequests.Get(ctx, owner, repo, pullNumber) if err != nil { return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to get pull request", resp, err, ), nil } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { body, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("failed to read response body: %w", err) } return utils.NewToolResultError(fmt.Sprintf("failed to get pull request: %s", string(body))), nil } // sanitize title/body on response if pr != nil { if pr.Title != nil { pr.Title = github.Ptr(sanitize.Sanitize(*pr.Title)) } if pr.Body != nil { pr.Body = github.Ptr(sanitize.Sanitize(*pr.Body)) } } if ff.LockdownMode { if cache == nil { return nil, fmt.Errorf("lockdown cache is not configured") } login := pr.GetUser().GetLogin() if login != "" { isSafeContent, err := cache.IsSafeContent(ctx, login, owner, repo) if err != nil { return nil, fmt.Errorf("failed to check content removal: %w", err) } if !isSafeContent { return utils.NewToolResultError("access to pull request is restricted by lockdown mode"), nil } } } r, err := json.Marshal(pr) if err != nil { return nil, fmt.Errorf("failed to marshal response: %w", err) } return utils.NewToolResultText(string(r)), nil } func GetPullRequestDiff(ctx context.Context, client *github.Client, owner, repo string, pullNumber int) (*mcp.CallToolResult, error) { raw, resp, err := client.PullRequests.GetRaw( ctx, owner, repo, pullNumber, github.RawOptions{Type: github.Diff}, ) if err != nil { return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to get pull request diff", resp, err, ), nil } if resp.StatusCode != http.StatusOK { body, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("failed to read response body: %w", err) } return utils.NewToolResultError(fmt.Sprintf("failed to get pull request diff: %s", string(body))), nil } defer func() { _ = resp.Body.Close() }() // Return the raw response return utils.NewToolResultText(string(raw)), nil } func GetPullRequestStatus(ctx context.Context, client *github.Client, owner, repo string, pullNumber int) (*mcp.CallToolResult, error) { pr, resp, err := client.PullRequests.Get(ctx, owner, repo, pullNumber) if err != nil { return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to get pull request", resp, err, ), nil } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { body, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("failed to read response body: %w", err) } return utils.NewToolResultError(fmt.Sprintf("failed to get pull request: %s", string(body))), nil } // Get combined status for the head SHA status, resp, err := client.Repositories.GetCombinedStatus(ctx, owner, repo, *pr.Head.SHA, nil) if err != nil { return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to get combined status", resp, err, ), nil } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { body, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("failed to read response body: %w", err) } return utils.NewToolResultError(fmt.Sprintf("failed to get combined status: %s", string(body))), nil } r, err := json.Marshal(status) if err != nil { return nil, fmt.Errorf("failed to marshal response: %w", err) } return utils.NewToolResultText(string(r)), nil } func GetPullRequestFiles(ctx context.Context, client *github.Client, owner, repo string, pullNumber int, pagination PaginationParams) (*mcp.CallToolResult, error) { opts := &github.ListOptions{ PerPage: pagination.PerPage, Page: pagination.Page, } files, resp, err := client.PullRequests.ListFiles(ctx, owner, repo, pullNumber, opts) if err != nil { return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to get pull request files", resp, err, ), nil } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { body, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("failed to read response body: %w", err) } return utils.NewToolResultError(fmt.Sprintf("failed to get pull request files: %s", string(body))), nil } r, err := json.Marshal(files) if err != nil { return nil, fmt.Errorf("failed to marshal response: %w", err) } return utils.NewToolResultText(string(r)), nil } func GetPullRequestReviewComments(ctx context.Context, client *github.Client, cache *lockdown.RepoAccessCache, owner, repo string, pullNumber int, pagination PaginationParams, ff FeatureFlags) (*mcp.CallToolResult, error) { opts := &github.PullRequestListCommentsOptions{ ListOptions: github.ListOptions{ PerPage: pagination.PerPage, Page: pagination.Page, }, } comments, resp, err := client.PullRequests.ListComments(ctx, owner, repo, pullNumber, opts) if err != nil { return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to get pull request review comments", resp, err, ), nil } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { body, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("failed to read response body: %w", err) } return utils.NewToolResultError(fmt.Sprintf("failed to get pull request review comments: %s", string(body))), nil } if ff.LockdownMode { if cache == nil { return nil, fmt.Errorf("lockdown cache is not configured") } filteredComments := make([]*github.PullRequestComment, 0, len(comments)) for _, comment := range comments { user := comment.GetUser() if user == nil { continue } isSafeContent, err := cache.IsSafeContent(ctx, user.GetLogin(), owner, repo) if err != nil { return utils.NewToolResultError(fmt.Sprintf("failed to check lockdown mode: %v", err)), nil } if isSafeContent { filteredComments = append(filteredComments, comment) } } comments = filteredComments } r, err := json.Marshal(comments) if err != nil { return nil, fmt.Errorf("failed to marshal response: %w", err) } return utils.NewToolResultText(string(r)), nil } func GetPullRequestReviews(ctx context.Context, client *github.Client, cache *lockdown.RepoAccessCache, owner, repo string, pullNumber int, ff FeatureFlags) (*mcp.CallToolResult, error) { reviews, resp, err := client.PullRequests.ListReviews(ctx, owner, repo, pullNumber, nil) if err != nil { return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to get pull request reviews", resp, err, ), nil } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { body, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("failed to read response body: %w", err) } return utils.NewToolResultError(fmt.Sprintf("failed to get pull request reviews: %s", string(body))), nil } if ff.LockdownMode { if cache == nil { return nil, fmt.Errorf("lockdown cache is not configured") } filteredReviews := make([]*github.PullRequestReview, 0, len(reviews)) for _, review := range reviews { login := review.GetUser().GetLogin() if login != "" { isSafeContent, err := cache.IsSafeContent(ctx, login, owner, repo) if err != nil { return nil, fmt.Errorf("failed to check lockdown mode: %w", err) } if isSafeContent { filteredReviews = append(filteredReviews, review) } reviews = filteredReviews } } } r, err := json.Marshal(reviews) if err != nil { return nil, fmt.Errorf("failed to marshal response: %w", err) } return utils.NewToolResultText(string(r)), nil } // CreatePullRequest creates a tool to create a new pull request. func CreatePullRequest(getClient GetClientFn, t translations.TranslationHelperFunc) (mcp.Tool, mcp.ToolHandlerFor[map[string]any, any]) { schema := &jsonschema.Schema{ Type: "object", Properties: map[string]*jsonschema.Schema{ "owner": { Type: "string", Description: "Repository owner", }, "repo": { Type: "string", Description: "Repository name", }, "title": { Type: "string", Description: "PR title", }, "body": { Type: "string", Description: "PR description", }, "head": { Type: "string", Description: "Branch containing changes", }, "base": { Type: "string", Description: "Branch to merge into", }, "draft": { Type: "boolean", Description: "Create as draft PR", }, "maintainer_can_modify": { Type: "boolean", Description: "Allow maintainer edits", }, }, Required: []string{"owner", "repo", "title", "head", "base"}, } return mcp.Tool{ Name: "create_pull_request", Description: t("TOOL_CREATE_PULL_REQUEST_DESCRIPTION", "Create a new pull request in a GitHub repository."), Annotations: &mcp.ToolAnnotations{ Title: t("TOOL_CREATE_PULL_REQUEST_USER_TITLE", "Open new pull request"), ReadOnlyHint: false, }, InputSchema: schema, }, func(ctx context.Context, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { owner, err := RequiredParam[string](args, "owner") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } repo, err := RequiredParam[string](args, "repo") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } title, err := RequiredParam[string](args, "title") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } head, err := RequiredParam[string](args, "head") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } base, err := RequiredParam[string](args, "base") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } body, err := OptionalParam[string](args, "body") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } draft, err := OptionalParam[bool](args, "draft") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } maintainerCanModify, err := OptionalParam[bool](args, "maintainer_can_modify") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } newPR := &github.NewPullRequest{ Title: github.Ptr(title), Head: github.Ptr(head), Base: github.Ptr(base), } if body != "" { newPR.Body = github.Ptr(body) } newPR.Draft = github.Ptr(draft) newPR.MaintainerCanModify = github.Ptr(maintainerCanModify) client, err := getClient(ctx) if err != nil { return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil } pr, resp, err := client.PullRequests.Create(ctx, owner, repo, newPR) if err != nil { return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to create pull request", resp, err, ), nil, nil } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusCreated { bodyBytes, err := io.ReadAll(resp.Body) if err != nil { return utils.NewToolResultErrorFromErr("failed to read response body", err), nil, nil } return utils.NewToolResultError(fmt.Sprintf("failed to create pull request: %s", string(bodyBytes))), nil, nil } // Return minimal response with just essential information minimalResponse := MinimalResponse{ ID: fmt.Sprintf("%d", pr.GetID()), URL: pr.GetHTMLURL(), } r, err := json.Marshal(minimalResponse) if err != nil { return utils.NewToolResultErrorFromErr("failed to marshal response", err), nil, nil } return utils.NewToolResultText(string(r)), nil, nil } } // UpdatePullRequest creates a tool to update an existing pull request. func UpdatePullRequest(getClient GetClientFn, getGQLClient GetGQLClientFn, t translations.TranslationHelperFunc) (mcp.Tool, mcp.ToolHandlerFor[map[string]any, any]) { schema := &jsonschema.Schema{ Type: "object", Properties: map[string]*jsonschema.Schema{ "owner": { Type: "string", Description: "Repository owner", }, "repo": { Type: "string", Description: "Repository name", }, "pullNumber": { Type: "number", Description: "Pull request number to update", }, "title": { Type: "string", Description: "New title", }, "body": { Type: "string", Description: "New description", }, "state": { Type: "string", Description: "New state", Enum: []any{"open", "closed"}, }, "draft": { Type: "boolean", Description: "Mark pull request as draft (true) or ready for review (false)", }, "base": { Type: "string", Description: "New base branch name", }, "maintainer_can_modify": { Type: "boolean", Description: "Allow maintainer edits", }, "reviewers": { Type: "array", Description: "GitHub usernames to request reviews from", Items: &jsonschema.Schema{ Type: "string", }, }, }, Required: []string{"owner", "repo", "pullNumber"}, } return mcp.Tool{ Name: "update_pull_request", Description: t("TOOL_UPDATE_PULL_REQUEST_DESCRIPTION", "Update an existing pull request in a GitHub repository."), Annotations: &mcp.ToolAnnotations{ Title: t("TOOL_UPDATE_PULL_REQUEST_USER_TITLE", "Edit pull request"), ReadOnlyHint: false, }, InputSchema: schema, }, func(ctx context.Context, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { owner, err := RequiredParam[string](args, "owner") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } repo, err := RequiredParam[string](args, "repo") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } pullNumber, err := RequiredInt(args, "pullNumber") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } _, draftProvided := args["draft"] var draftValue bool if draftProvided { draftValue, err = OptionalParam[bool](args, "draft") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } } update := &github.PullRequest{} restUpdateNeeded := false if title, ok, err := OptionalParamOK[string](args, "title"); err != nil { return utils.NewToolResultError(err.Error()), nil, nil } else if ok { update.Title = github.Ptr(title) restUpdateNeeded = true } if body, ok, err := OptionalParamOK[string](args, "body"); err != nil { return utils.NewToolResultError(err.Error()), nil, nil } else if ok { update.Body = github.Ptr(body) restUpdateNeeded = true } if state, ok, err := OptionalParamOK[string](args, "state"); err != nil { return utils.NewToolResultError(err.Error()), nil, nil } else if ok { update.State = github.Ptr(state) restUpdateNeeded = true } if base, ok, err := OptionalParamOK[string](args, "base"); err != nil { return utils.NewToolResultError(err.Error()), nil, nil } else if ok { update.Base = &github.PullRequestBranch{Ref: github.Ptr(base)} restUpdateNeeded = true } if maintainerCanModify, ok, err := OptionalParamOK[bool](args, "maintainer_can_modify"); err != nil { return utils.NewToolResultError(err.Error()), nil, nil } else if ok { update.MaintainerCanModify = github.Ptr(maintainerCanModify) restUpdateNeeded = true } // Handle reviewers separately reviewers, err := OptionalStringArrayParam(args, "reviewers") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } // If no updates, no draft change, and no reviewers, return error early if !restUpdateNeeded && !draftProvided && len(reviewers) == 0 { return utils.NewToolResultError("No update parameters provided."), nil, nil } // Handle REST API updates (title, body, state, base, maintainer_can_modify) if restUpdateNeeded { client, err := getClient(ctx) if err != nil { return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil } _, resp, err := client.PullRequests.Edit(ctx, owner, repo, pullNumber, update) if err != nil { return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to update pull request", resp, err, ), nil, nil } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { bodyBytes, err := io.ReadAll(resp.Body) if err != nil { return utils.NewToolResultErrorFromErr("failed to read response body", err), nil, nil } return utils.NewToolResultError(fmt.Sprintf("failed to update pull request: %s", string(bodyBytes))), nil, nil } } // Handle draft status changes using GraphQL if draftProvided { gqlClient, err := getGQLClient(ctx) if err != nil { return utils.NewToolResultErrorFromErr("failed to get GitHub GraphQL client", err), nil, nil } var prQuery struct { Repository struct { PullRequest struct { ID githubv4.ID IsDraft githubv4.Boolean } `graphql:"pullRequest(number: $prNum)"` } `graphql:"repository(owner: $owner, name: $repo)"` } err = gqlClient.Query(ctx, &prQuery, map[string]interface{}{ "owner": githubv4.String(owner), "repo": githubv4.String(repo), "prNum": githubv4.Int(pullNumber), // #nosec G115 - pull request numbers are always small positive integers }) if err != nil { return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "Failed to find pull request", err), nil, nil } currentIsDraft := bool(prQuery.Repository.PullRequest.IsDraft) if currentIsDraft != draftValue { if draftValue { // Convert to draft var mutation struct { ConvertPullRequestToDraft struct { PullRequest struct { ID githubv4.ID IsDraft githubv4.Boolean } } `graphql:"convertPullRequestToDraft(input: $input)"` } err = gqlClient.Mutate(ctx, &mutation, githubv4.ConvertPullRequestToDraftInput{ PullRequestID: prQuery.Repository.PullRequest.ID, }, nil) if err != nil { return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "Failed to convert pull request to draft", err), nil, nil } } else { // Mark as ready for review var mutation struct { MarkPullRequestReadyForReview struct { PullRequest struct { ID githubv4.ID IsDraft githubv4.Boolean } } `graphql:"markPullRequestReadyForReview(input: $input)"` } err = gqlClient.Mutate(ctx, &mutation, githubv4.MarkPullRequestReadyForReviewInput{ PullRequestID: prQuery.Repository.PullRequest.ID, }, nil) if err != nil { return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "Failed to mark pull request ready for review", err), nil, nil } } } } // Handle reviewer requests if len(reviewers) > 0 { client, err := getClient(ctx) if err != nil { return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil } reviewersRequest := github.ReviewersRequest{ Reviewers: reviewers, } _, resp, err := client.PullRequests.RequestReviewers(ctx, owner, repo, pullNumber, reviewersRequest) if err != nil { return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to request reviewers", resp, err, ), nil, nil } defer func() { if resp != nil && resp.Body != nil { _ = resp.Body.Close() } }() if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK { bodyBytes, err := io.ReadAll(resp.Body) if err != nil { return utils.NewToolResultErrorFromErr("failed to read response body", err), nil, nil } return utils.NewToolResultError(fmt.Sprintf("failed to request reviewers: %s", string(bodyBytes))), nil, nil } } // Get the final state of the PR to return client, err := getClient(ctx) if err != nil { return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil } finalPR, resp, err := client.PullRequests.Get(ctx, owner, repo, pullNumber) if err != nil { return ghErrors.NewGitHubAPIErrorResponse(ctx, "Failed to get pull request", resp, err), nil, nil } defer func() { if resp != nil && resp.Body != nil { _ = resp.Body.Close() } }() // Return minimal response with just essential information minimalResponse := MinimalResponse{ ID: fmt.Sprintf("%d", finalPR.GetID()), URL: finalPR.GetHTMLURL(), } r, err := json.Marshal(minimalResponse) if err != nil { return utils.NewToolResultErrorFromErr("Failed to marshal response", err), nil, nil } return utils.NewToolResultText(string(r)), nil, nil } } // ListPullRequests creates a tool to list and filter repository pull requests. func ListPullRequests(getClient GetClientFn, t translations.TranslationHelperFunc) (mcp.Tool, mcp.ToolHandlerFor[map[string]any, any]) { schema := &jsonschema.Schema{ Type: "object", Properties: map[string]*jsonschema.Schema{ "owner": { Type: "string", Description: "Repository owner", }, "repo": { Type: "string", Description: "Repository name", }, "state": { Type: "string", Description: "Filter by state", Enum: []any{"open", "closed", "all"}, }, "head": { Type: "string", Description: "Filter by head user/org and branch", }, "base": { Type: "string", Description: "Filter by base branch", }, "sort": { Type: "string", Description: "Sort by", Enum: []any{"created", "updated", "popularity", "long-running"}, }, "direction": { Type: "string", Description: "Sort direction", Enum: []any{"asc", "desc"}, }, }, Required: []string{"owner", "repo"}, } WithPagination(schema) return mcp.Tool{ Name: "list_pull_requests", Description: t("TOOL_LIST_PULL_REQUESTS_DESCRIPTION", "List pull requests in a GitHub repository. If the user specifies an author, then DO NOT use this tool and use the search_pull_requests tool instead."), Annotations: &mcp.ToolAnnotations{ Title: t("TOOL_LIST_PULL_REQUESTS_USER_TITLE", "List pull requests"), ReadOnlyHint: true, }, InputSchema: schema, }, func(ctx context.Context, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { owner, err := RequiredParam[string](args, "owner") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } repo, err := RequiredParam[string](args, "repo") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } state, err := OptionalParam[string](args, "state") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } head, err := OptionalParam[string](args, "head") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } base, err := OptionalParam[string](args, "base") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } sort, err := OptionalParam[string](args, "sort") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } direction, err := OptionalParam[string](args, "direction") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } pagination, err := OptionalPaginationParams(args) if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } opts := &github.PullRequestListOptions{ State: state, Head: head, Base: base, Sort: sort, Direction: direction, ListOptions: github.ListOptions{ PerPage: pagination.PerPage, Page: pagination.Page, }, } client, err := getClient(ctx) if err != nil { return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil } prs, resp, err := client.PullRequests.List(ctx, owner, repo, opts) if err != nil { return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to list pull requests", resp, err, ), nil, nil } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { bodyBytes, err := io.ReadAll(resp.Body) if err != nil { return utils.NewToolResultErrorFromErr("failed to read response body", err), nil, nil } return utils.NewToolResultError(fmt.Sprintf("failed to list pull requests: %s", string(bodyBytes))), nil, nil } // sanitize title/body on each PR for _, pr := range prs { if pr == nil { continue } if pr.Title != nil { pr.Title = github.Ptr(sanitize.Sanitize(*pr.Title)) } if pr.Body != nil { pr.Body = github.Ptr(sanitize.Sanitize(*pr.Body)) } } r, err := json.Marshal(prs) if err != nil { return utils.NewToolResultErrorFromErr("failed to marshal response", err), nil, nil } return utils.NewToolResultText(string(r)), nil, nil } } // MergePullRequest creates a tool to merge a pull request. func MergePullRequest(getClient GetClientFn, t translations.TranslationHelperFunc) (mcp.Tool, mcp.ToolHandlerFor[map[string]any, any]) { schema := &jsonschema.Schema{ Type: "object", Properties: map[string]*jsonschema.Schema{ "owner": { Type: "string", Description: "Repository owner", }, "repo": { Type: "string", Description: "Repository name", }, "pullNumber": { Type: "number", Description: "Pull request number", }, "commit_title": { Type: "string", Description: "Title for merge commit", }, "commit_message": { Type: "string", Description: "Extra detail for merge commit", }, "merge_method": { Type: "string", Description: "Merge method", Enum: []any{"merge", "squash", "rebase"}, }, }, Required: []string{"owner", "repo", "pullNumber"}, } return mcp.Tool{ Name: "merge_pull_request", Description: t("TOOL_MERGE_PULL_REQUEST_DESCRIPTION", "Merge a pull request in a GitHub repository."), Annotations: &mcp.ToolAnnotations{ Title: t("TOOL_MERGE_PULL_REQUEST_USER_TITLE", "Merge pull request"), ReadOnlyHint: false, }, InputSchema: schema, }, func(ctx context.Context, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { owner, err := RequiredParam[string](args, "owner") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } repo, err := RequiredParam[string](args, "repo") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } pullNumber, err := RequiredInt(args, "pullNumber") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } commitTitle, err := OptionalParam[string](args, "commit_title") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } commitMessage, err := OptionalParam[string](args, "commit_message") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } mergeMethod, err := OptionalParam[string](args, "merge_method") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } options := &github.PullRequestOptions{ CommitTitle: commitTitle, MergeMethod: mergeMethod, } client, err := getClient(ctx) if err != nil { return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil } result, resp, err := client.PullRequests.Merge(ctx, owner, repo, pullNumber, commitMessage, options) if err != nil { return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to merge pull request", resp, err, ), nil, nil } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { bodyBytes, err := io.ReadAll(resp.Body) if err != nil { return utils.NewToolResultErrorFromErr("failed to read response body", err), nil, nil } return utils.NewToolResultError(fmt.Sprintf("failed to merge pull request: %s", string(bodyBytes))), nil, nil } r, err := json.Marshal(result) if err != nil { return utils.NewToolResultErrorFromErr("failed to marshal response", err), nil, nil } return utils.NewToolResultText(string(r)), nil, nil } } // SearchPullRequests creates a tool to search for pull requests. func SearchPullRequests(getClient GetClientFn, t translations.TranslationHelperFunc) (mcp.Tool, mcp.ToolHandlerFor[map[string]any, any]) { schema := &jsonschema.Schema{ Type: "object", Properties: map[string]*jsonschema.Schema{ "query": { Type: "string", Description: "Search query using GitHub pull request search syntax", }, "owner": { Type: "string", Description: "Optional repository owner. If provided with repo, only pull requests for this repository are listed.", }, "repo": { Type: "string", Description: "Optional repository name. If provided with owner, only pull requests for this repository are listed.", }, "sort": { Type: "string", Description: "Sort field by number of matches of categories, defaults to best match", Enum: []any{ "comments", "reactions", "reactions-+1", "reactions--1", "reactions-smile", "reactions-thinking_face", "reactions-heart", "reactions-tada", "interactions", "created", "updated", }, }, "order": { Type: "string", Description: "Sort order", Enum: []any{"asc", "desc"}, }, }, Required: []string{"query"}, } WithPagination(schema) return mcp.Tool{ Name: "search_pull_requests", Description: t("TOOL_SEARCH_PULL_REQUESTS_DESCRIPTION", "Search for pull requests in GitHub repositories using issues search syntax already scoped to is:pr"), Annotations: &mcp.ToolAnnotations{ Title: t("TOOL_SEARCH_PULL_REQUESTS_USER_TITLE", "Search pull requests"), ReadOnlyHint: true, }, InputSchema: schema, }, func(ctx context.Context, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { result, err := searchHandler(ctx, getClient, args, "pr", "failed to search pull requests") return result, nil, err } } // UpdatePullRequestBranch creates a tool to update a pull request branch with the latest changes from the base branch. func UpdatePullRequestBranch(getClient GetClientFn, t translations.TranslationHelperFunc) (mcp.Tool, mcp.ToolHandlerFor[map[string]any, any]) { schema := &jsonschema.Schema{ Type: "object", Properties: map[string]*jsonschema.Schema{ "owner": { Type: "string", Description: "Repository owner", }, "repo": { Type: "string", Description: "Repository name", }, "pullNumber": { Type: "number", Description: "Pull request number", }, "expectedHeadSha": { Type: "string", Description: "The expected SHA of the pull request's HEAD ref", }, }, Required: []string{"owner", "repo", "pullNumber"}, } return mcp.Tool{ Name: "update_pull_request_branch", Description: t("TOOL_UPDATE_PULL_REQUEST_BRANCH_DESCRIPTION", "Update the branch of a pull request with the latest changes from the base branch."), Annotations: &mcp.ToolAnnotations{ Title: t("TOOL_UPDATE_PULL_REQUEST_BRANCH_USER_TITLE", "Update pull request branch"), ReadOnlyHint: false, }, InputSchema: schema, }, func(ctx context.Context, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { owner, err := RequiredParam[string](args, "owner") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } repo, err := RequiredParam[string](args, "repo") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } pullNumber, err := RequiredInt(args, "pullNumber") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } expectedHeadSHA, err := OptionalParam[string](args, "expectedHeadSha") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } opts := &github.PullRequestBranchUpdateOptions{} if expectedHeadSHA != "" { opts.ExpectedHeadSHA = github.Ptr(expectedHeadSHA) } client, err := getClient(ctx) if err != nil { return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil } result, resp, err := client.PullRequests.UpdateBranch(ctx, owner, repo, pullNumber, opts) if err != nil { // Check if it's an acceptedError. An acceptedError indicates that the update is in progress, // and it's not a real error. if resp != nil && resp.StatusCode == http.StatusAccepted && isAcceptedError(err) { return utils.NewToolResultText("Pull request branch update is in progress"), nil, nil } return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to update pull request branch", resp, err, ), nil, nil } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusAccepted { bodyBytes, err := io.ReadAll(resp.Body) if err != nil { return utils.NewToolResultErrorFromErr("failed to read response body", err), nil, nil } return utils.NewToolResultError(fmt.Sprintf("failed to update pull request branch: %s", string(bodyBytes))), nil, nil } r, err := json.Marshal(result) if err != nil { return utils.NewToolResultErrorFromErr("failed to marshal response", err), nil, nil } return utils.NewToolResultText(string(r)), nil, nil } } type PullRequestReviewWriteParams struct { Method string Owner string Repo string PullNumber int32 Body string Event string CommitID *string } func PullRequestReviewWrite(getGQLClient GetGQLClientFn, t translations.TranslationHelperFunc) (mcp.Tool, mcp.ToolHandlerFor[map[string]any, any]) { schema := &jsonschema.Schema{ Type: "object", Properties: map[string]*jsonschema.Schema{ // Either we need the PR GQL Id directly, or we need owner, repo and PR number to look it up. // Since our other Pull Request tools are working with the REST Client, will handle the lookup // internally for now. "method": { Type: "string", Description: `The write operation to perform on pull request review.`, Enum: []any{"create", "submit_pending", "delete_pending"}, }, "owner": { Type: "string", Description: "Repository owner", }, "repo": { Type: "string", Description: "Repository name", }, "pullNumber": { Type: "number", Description: "Pull request number", }, "body": { Type: "string", Description: "Review comment text", }, "event": { Type: "string", Description: "Review action to perform.", Enum: []any{"APPROVE", "REQUEST_CHANGES", "COMMENT"}, }, "commitID": { Type: "string", Description: "SHA of commit to review", }, }, Required: []string{"method", "owner", "repo", "pullNumber"}, } return mcp.Tool{ Name: "pull_request_review_write", Description: t("TOOL_PULL_REQUEST_REVIEW_WRITE_DESCRIPTION", `Create and/or submit, delete review of a pull request. Available methods: - create: Create a new review of a pull request. If "event" parameter is provided, the review is submitted. If "event" is omitted, a pending review is created. - submit_pending: Submit an existing pending review of a pull request. This requires that a pending review exists for the current user on the specified pull request. The "body" and "event" parameters are used when submitting the review. - delete_pending: Delete an existing pending review of a pull request. This requires that a pending review exists for the current user on the specified pull request. `), Annotations: &mcp.ToolAnnotations{ Title: t("TOOL_PULL_REQUEST_REVIEW_WRITE_USER_TITLE", "Write operations (create, submit, delete) on pull request reviews."), ReadOnlyHint: false, }, InputSchema: schema, }, func(ctx context.Context, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { var params PullRequestReviewWriteParams if err := mapstructure.Decode(args, ¶ms); err != nil { return utils.NewToolResultError(err.Error()), nil, nil } // Given our owner, repo and PR number, lookup the GQL ID of the PR. client, err := getGQLClient(ctx) if err != nil { return utils.NewToolResultError(fmt.Sprintf("failed to get GitHub GQL client: %v", err)), nil, nil } switch params.Method { case "create": result, err := CreatePullRequestReview(ctx, client, params) return result, nil, err case "submit_pending": result, err := SubmitPendingPullRequestReview(ctx, client, params) return result, nil, err case "delete_pending": result, err := DeletePendingPullRequestReview(ctx, client, params) return result, nil, err default: return utils.NewToolResultError(fmt.Sprintf("unknown method: %s", params.Method)), nil, nil } } } func CreatePullRequestReview(ctx context.Context, client *githubv4.Client, params PullRequestReviewWriteParams) (*mcp.CallToolResult, error) { var getPullRequestQuery struct { Repository struct { PullRequest struct { ID githubv4.ID } `graphql:"pullRequest(number: $prNum)"` } `graphql:"repository(owner: $owner, name: $repo)"` } if err := client.Query(ctx, &getPullRequestQuery, map[string]any{ "owner": githubv4.String(params.Owner), "repo": githubv4.String(params.Repo), "prNum": githubv4.Int(params.PullNumber), }); err != nil { return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "failed to get pull request", err, ), nil } // Now we have the GQL ID, we can create a review var addPullRequestReviewMutation struct { AddPullRequestReview struct { PullRequestReview struct { ID githubv4.ID // We don't need this, but a selector is required or GQL complains. } } `graphql:"addPullRequestReview(input: $input)"` } addPullRequestReviewInput := githubv4.AddPullRequestReviewInput{ PullRequestID: getPullRequestQuery.Repository.PullRequest.ID, CommitOID: newGQLStringlikePtr[githubv4.GitObjectID](params.CommitID), } // Event and Body are provided if we submit a review if params.Event != "" { addPullRequestReviewInput.Event = newGQLStringlike[githubv4.PullRequestReviewEvent](params.Event) addPullRequestReviewInput.Body = githubv4.NewString(githubv4.String(params.Body)) } if err := client.Mutate( ctx, &addPullRequestReviewMutation, addPullRequestReviewInput, nil, ); err != nil { return utils.NewToolResultError(err.Error()), nil } // Return nothing interesting, just indicate success for the time being. // In future, we may want to return the review ID, but for the moment, we're not leaking // API implementation details to the LLM. if params.Event == "" { return utils.NewToolResultText("pending pull request created"), nil } return utils.NewToolResultText("pull request review submitted successfully"), nil } func SubmitPendingPullRequestReview(ctx context.Context, client *githubv4.Client, params PullRequestReviewWriteParams) (*mcp.CallToolResult, error) { // First we'll get the current user var getViewerQuery struct { Viewer struct { Login githubv4.String } } if err := client.Query(ctx, &getViewerQuery, nil); err != nil { return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "failed to get current user", err, ), nil } var getLatestReviewForViewerQuery struct { Repository struct { PullRequest struct { Reviews struct { Nodes []struct { ID githubv4.ID State githubv4.PullRequestReviewState URL githubv4.URI } } `graphql:"reviews(first: 1, author: $author)"` } `graphql:"pullRequest(number: $prNum)"` } `graphql:"repository(owner: $owner, name: $name)"` } vars := map[string]any{ "author": githubv4.String(getViewerQuery.Viewer.Login), "owner": githubv4.String(params.Owner), "name": githubv4.String(params.Repo), "prNum": githubv4.Int(params.PullNumber), } if err := client.Query(context.Background(), &getLatestReviewForViewerQuery, vars); err != nil { return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "failed to get latest review for current user", err, ), nil } // Validate there is one review and the state is pending if len(getLatestReviewForViewerQuery.Repository.PullRequest.Reviews.Nodes) == 0 { return utils.NewToolResultError("No pending review found for the viewer"), nil } review := getLatestReviewForViewerQuery.Repository.PullRequest.Reviews.Nodes[0] if review.State != githubv4.PullRequestReviewStatePending { errText := fmt.Sprintf("The latest review, found at %s is not pending", review.URL) return utils.NewToolResultError(errText), nil } // Prepare the mutation var submitPullRequestReviewMutation struct { SubmitPullRequestReview struct { PullRequestReview struct { ID githubv4.ID // We don't need this, but a selector is required or GQL complains. } } `graphql:"submitPullRequestReview(input: $input)"` } if err := client.Mutate( ctx, &submitPullRequestReviewMutation, githubv4.SubmitPullRequestReviewInput{ PullRequestReviewID: &review.ID, Event: githubv4.PullRequestReviewEvent(params.Event), Body: newGQLStringlikePtr[githubv4.String](¶ms.Body), }, nil, ); err != nil { return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "failed to submit pull request review", err, ), nil } // Return nothing interesting, just indicate success for the time being. // In future, we may want to return the review ID, but for the moment, we're not leaking // API implementation details to the LLM. return utils.NewToolResultText("pending pull request review successfully submitted"), nil } func DeletePendingPullRequestReview(ctx context.Context, client *githubv4.Client, params PullRequestReviewWriteParams) (*mcp.CallToolResult, error) { // First we'll get the current user var getViewerQuery struct { Viewer struct { Login githubv4.String } } if err := client.Query(ctx, &getViewerQuery, nil); err != nil { return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "failed to get current user", err, ), nil } var getLatestReviewForViewerQuery struct { Repository struct { PullRequest struct { Reviews struct { Nodes []struct { ID githubv4.ID State githubv4.PullRequestReviewState URL githubv4.URI } } `graphql:"reviews(first: 1, author: $author)"` } `graphql:"pullRequest(number: $prNum)"` } `graphql:"repository(owner: $owner, name: $name)"` } vars := map[string]any{ "author": githubv4.String(getViewerQuery.Viewer.Login), "owner": githubv4.String(params.Owner), "name": githubv4.String(params.Repo), "prNum": githubv4.Int(params.PullNumber), } if err := client.Query(context.Background(), &getLatestReviewForViewerQuery, vars); err != nil { return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "failed to get latest review for current user", err, ), nil } // Validate there is one review and the state is pending if len(getLatestReviewForViewerQuery.Repository.PullRequest.Reviews.Nodes) == 0 { return utils.NewToolResultError("No pending review found for the viewer"), nil } review := getLatestReviewForViewerQuery.Repository.PullRequest.Reviews.Nodes[0] if review.State != githubv4.PullRequestReviewStatePending { errText := fmt.Sprintf("The latest review, found at %s is not pending", review.URL) return utils.NewToolResultError(errText), nil } // Prepare the mutation var deletePullRequestReviewMutation struct { DeletePullRequestReview struct { PullRequestReview struct { ID githubv4.ID // We don't need this, but a selector is required or GQL complains. } } `graphql:"deletePullRequestReview(input: $input)"` } if err := client.Mutate( ctx, &deletePullRequestReviewMutation, githubv4.DeletePullRequestReviewInput{ PullRequestReviewID: &review.ID, }, nil, ); err != nil { return utils.NewToolResultError(err.Error()), nil } // Return nothing interesting, just indicate success for the time being. // In future, we may want to return the review ID, but for the moment, we're not leaking // API implementation details to the LLM. return utils.NewToolResultText("pending pull request review successfully deleted"), nil } // AddCommentToPendingReview creates a tool to add a comment to a pull request review. func AddCommentToPendingReview(getGQLClient GetGQLClientFn, t translations.TranslationHelperFunc) (mcp.Tool, mcp.ToolHandlerFor[map[string]any, any]) { schema := &jsonschema.Schema{ Type: "object", Properties: map[string]*jsonschema.Schema{ // Ideally, for performance sake this would just accept the pullRequestReviewID. However, we would need to // add a new tool to get that ID for clients that aren't in the same context as the original pending review // creation. So for now, we'll just accept the owner, repo and pull number and assume this is adding a comment // the latest review from a user, since only one can be active at a time. It can later be extended with // a pullRequestReviewID parameter if targeting other reviews is desired: // mcp.WithString("pullRequestReviewID", // mcp.Required(), // mcp.Description("The ID of the pull request review to add a comment to"), // ), "owner": { Type: "string", Description: "Repository owner", }, "repo": { Type: "string", Description: "Repository name", }, "pullNumber": { Type: "number", Description: "Pull request number", }, "path": { Type: "string", Description: "The relative path to the file that necessitates a comment", }, "body": { Type: "string", Description: "The text of the review comment", }, "subjectType": { Type: "string", Description: "The level at which the comment is targeted", Enum: []any{"FILE", "LINE"}, }, "line": { Type: "number", Description: "The line of the blob in the pull request diff that the comment applies to. For multi-line comments, the last line of the range", }, "side": { Type: "string", Description: "The side of the diff to comment on. LEFT indicates the previous state, RIGHT indicates the new state", Enum: []any{"LEFT", "RIGHT"}, }, "startLine": { Type: "number", Description: "For multi-line comments, the first line of the range that the comment applies to", }, "startSide": { Type: "string", Description: "For multi-line comments, the starting side of the diff that the comment applies to. LEFT indicates the previous state, RIGHT indicates the new state", Enum: []any{"LEFT", "RIGHT"}, }, }, Required: []string{"owner", "repo", "pullNumber", "path", "body", "subjectType"}, } return mcp.Tool{ Name: "add_comment_to_pending_review", Description: t("TOOL_ADD_COMMENT_TO_PENDING_REVIEW_DESCRIPTION", "Add review comment to the requester's latest pending pull request review. A pending review needs to already exist to call this (check with the user if not sure)."), Annotations: &mcp.ToolAnnotations{ Title: t("TOOL_ADD_COMMENT_TO_PENDING_REVIEW_USER_TITLE", "Add review comment to the requester's latest pending pull request review"), ReadOnlyHint: false, }, InputSchema: schema, }, func(ctx context.Context, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { var params struct { Owner string Repo string PullNumber int32 Path string Body string SubjectType string Line *int32 Side *string StartLine *int32 StartSide *string } if err := mapstructure.Decode(args, ¶ms); err != nil { return utils.NewToolResultError(err.Error()), nil, nil } client, err := getGQLClient(ctx) if err != nil { return utils.NewToolResultErrorFromErr("failed to get GitHub GQL client", err), nil, nil } // First we'll get the current user var getViewerQuery struct { Viewer struct { Login githubv4.String } } if err := client.Query(ctx, &getViewerQuery, nil); err != nil { return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "failed to get current user", err, ), nil, nil } var getLatestReviewForViewerQuery struct { Repository struct { PullRequest struct { Reviews struct { Nodes []struct { ID githubv4.ID State githubv4.PullRequestReviewState URL githubv4.URI } } `graphql:"reviews(first: 1, author: $author)"` } `graphql:"pullRequest(number: $prNum)"` } `graphql:"repository(owner: $owner, name: $name)"` } vars := map[string]any{ "author": githubv4.String(getViewerQuery.Viewer.Login), "owner": githubv4.String(params.Owner), "name": githubv4.String(params.Repo), "prNum": githubv4.Int(params.PullNumber), } if err := client.Query(context.Background(), &getLatestReviewForViewerQuery, vars); err != nil { return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "failed to get latest review for current user", err, ), nil, nil } // Validate there is one review and the state is pending if len(getLatestReviewForViewerQuery.Repository.PullRequest.Reviews.Nodes) == 0 { return utils.NewToolResultError("No pending review found for the viewer"), nil, nil } review := getLatestReviewForViewerQuery.Repository.PullRequest.Reviews.Nodes[0] if review.State != githubv4.PullRequestReviewStatePending { errText := fmt.Sprintf("The latest review, found at %s is not pending", review.URL) return utils.NewToolResultError(errText), nil, nil } // Then we can create a new review thread comment on the review. var addPullRequestReviewThreadMutation struct { AddPullRequestReviewThread struct { Thread struct { ID githubv4.ID // We don't need this, but a selector is required or GQL complains. } } `graphql:"addPullRequestReviewThread(input: $input)"` } if err := client.Mutate( ctx, &addPullRequestReviewThreadMutation, githubv4.AddPullRequestReviewThreadInput{ Path: githubv4.String(params.Path), Body: githubv4.String(params.Body), SubjectType: newGQLStringlikePtr[githubv4.PullRequestReviewThreadSubjectType](¶ms.SubjectType), Line: newGQLIntPtr(params.Line), Side: newGQLStringlikePtr[githubv4.DiffSide](params.Side), StartLine: newGQLIntPtr(params.StartLine), StartSide: newGQLStringlikePtr[githubv4.DiffSide](params.StartSide), PullRequestReviewID: &review.ID, }, nil, ); err != nil { return utils.NewToolResultError(err.Error()), nil, nil } if addPullRequestReviewThreadMutation.AddPullRequestReviewThread.Thread.ID == nil { return utils.NewToolResultError(`Failed to add comment to pending review. Possible reasons: - The line number doesn't exist in the pull request diff - The file path is incorrect - The side (LEFT/RIGHT) is invalid for the specified line `), nil, nil } // Return nothing interesting, just indicate success for the time being. // In future, we may want to return the review ID, but for the moment, we're not leaking // API implementation details to the LLM. return utils.NewToolResultText("pull request review comment successfully added to pending review"), nil, nil } } // RequestCopilotReview creates a tool to request a Copilot review for a pull request. // Note that this tool will not work on GHES where this feature is unsupported. In future, we should not expose this // tool if the configured host does not support it. func RequestCopilotReview(getClient GetClientFn, t translations.TranslationHelperFunc) (mcp.Tool, mcp.ToolHandlerFor[map[string]any, any]) { schema := &jsonschema.Schema{ Type: "object", Properties: map[string]*jsonschema.Schema{ "owner": { Type: "string", Description: "Repository owner", }, "repo": { Type: "string", Description: "Repository name", }, "pullNumber": { Type: "number", Description: "Pull request number", }, }, Required: []string{"owner", "repo", "pullNumber"}, } return mcp.Tool{ Name: "request_copilot_review", Description: t("TOOL_REQUEST_COPILOT_REVIEW_DESCRIPTION", "Request a GitHub Copilot code review for a pull request. Use this for automated feedback on pull requests, usually before requesting a human reviewer."), Annotations: &mcp.ToolAnnotations{ Title: t("TOOL_REQUEST_COPILOT_REVIEW_USER_TITLE", "Request Copilot review"), ReadOnlyHint: false, }, InputSchema: schema, }, func(ctx context.Context, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { owner, err := RequiredParam[string](args, "owner") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } repo, err := RequiredParam[string](args, "repo") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } pullNumber, err := RequiredInt(args, "pullNumber") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } client, err := getClient(ctx) if err != nil { return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil } _, resp, err := client.PullRequests.RequestReviewers( ctx, owner, repo, pullNumber, github.ReviewersRequest{ // The login name of the copilot reviewer bot Reviewers: []string{"copilot-pull-request-reviewer[bot]"}, }, ) if err != nil { return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to request copilot review", resp, err, ), nil, nil } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusCreated { bodyBytes, err := io.ReadAll(resp.Body) if err != nil { return utils.NewToolResultErrorFromErr("failed to read response body", err), nil, nil } return utils.NewToolResultError(fmt.Sprintf("failed to request copilot review: %s", string(bodyBytes))), nil, nil } // Return nothing on success, as there's not much value in returning the Pull Request itself return utils.NewToolResultText(""), nil, nil } } // newGQLString like takes something that approximates a string (of which there are many types in shurcooL/githubv4) // and constructs a pointer to it, or nil if the string is empty. This is extremely useful because when we parse // params from the MCP request, we need to convert them to types that are pointers of type def strings and it's // not possible to take a pointer of an anonymous value e.g. &githubv4.String("foo"). func newGQLStringlike[T ~string](s string) *T { if s == "" { return nil } stringlike := T(s) return &stringlike } func newGQLStringlikePtr[T ~string](s *string) *T { if s == nil { return nil } stringlike := T(*s) return &stringlike } func newGQLIntPtr(i *int32) *githubv4.Int { if i == nil { return nil } gi := githubv4.Int(*i) return &gi }