Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1092,6 +1092,12 @@ The following sets of tools are available:
- `repo`: Repository name (string, required)
- `title`: PR title (string, required)

- **get_pull_request_metadata_batch** - Get batch pull request metadata
- **Required OAuth Scopes**: `repo`
- `owner`: Repository owner (string, required)
- `pullNumbers`: Explicit pull request numbers to hydrate. Accepts up to 25 items. (integer[], required)
- `repo`: Repository name (string, required)

- **list_pull_requests** - List pull requests
- **Required OAuth Scopes**: `repo`
- `base`: Filter by base branch (string, optional)
Expand Down
36 changes: 36 additions & 0 deletions pkg/github/__toolsnaps__/get_pull_request_metadata_batch.snap
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
{
"annotations": {
"readOnlyHint": true,
"title": "Get batch pull request metadata"
},
"description": "Get metadata for an explicit list of pull requests in a GitHub repository. Returns partial success with per-PR errors when some requested pull requests cannot be hydrated.",
"inputSchema": {
"properties": {
"owner": {
"description": "Repository owner",
"type": "string"
},
"pullNumbers": {
"description": "Explicit pull request numbers to hydrate. Accepts up to 25 items.",
"items": {
"minimum": 1,
"type": "integer"
},
"maxItems": 25,
"minItems": 1,
"type": "array"
},
"repo": {
"description": "Repository name",
"type": "string"
}
},
"required": [
"owner",
"repo",
"pullNumbers"
],
"type": "object"
},
"name": "get_pull_request_metadata_batch"
}
30 changes: 20 additions & 10 deletions pkg/github/pullrequests.go
Original file line number Diff line number Diff line change
Expand Up @@ -161,28 +161,40 @@ Possible options:
}

func GetPullRequest(ctx context.Context, client *github.Client, deps ToolDependencies, owner, repo string, pullNumber int) (*mcp.CallToolResult, error) {
minimalPR, toolErr, err := getMinimalPullRequest(ctx, client, deps, owner, repo, pullNumber)
if toolErr != nil || err != nil {
return toolErr, err
}

return MarshalledTextResult(minimalPR), nil
}

func getMinimalPullRequest(ctx context.Context, client *github.Client, deps ToolDependencies, owner, repo string, pullNumber int) (MinimalPullRequest, *mcp.CallToolResult, error) {
cache, err := deps.GetRepoAccessCache(ctx)
if err != nil {
return nil, fmt.Errorf("failed to get repo access cache: %w", err)
return MinimalPullRequest{}, nil, fmt.Errorf("failed to get repo access cache: %w", err)
}
ff := deps.GetFlags(ctx)

pr, resp, err := client.PullRequests.Get(ctx, owner, repo, pullNumber)
if err != nil {
return ghErrors.NewGitHubAPIErrorResponse(ctx,
return MinimalPullRequest{}, ghErrors.NewGitHubAPIErrorResponse(ctx,
"failed to get pull request",
resp,
err,
), nil
}
if resp == nil {
return MinimalPullRequest{}, nil, fmt.Errorf("missing GitHub response")
}
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 MinimalPullRequest{}, nil, fmt.Errorf("failed to read response body: %w", err)
}
return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to get pull request", resp, body), nil
return MinimalPullRequest{}, ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to get pull request", resp, body), nil
}

// sanitize title/body on response
Expand All @@ -197,24 +209,22 @@ func GetPullRequest(ctx context.Context, client *github.Client, deps ToolDepende

if ff.LockdownMode {
if cache == nil {
return nil, fmt.Errorf("lockdown cache is not configured")
return MinimalPullRequest{}, 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)
return MinimalPullRequest{}, 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
return MinimalPullRequest{}, utils.NewToolResultError("access to pull request is restricted by lockdown mode"), nil
}
}
}

minimalPR := convertToMinimalPullRequest(pr)

return MarshalledTextResult(minimalPR), nil
return convertToMinimalPullRequest(pr), nil, nil
}

func GetPullRequestDiff(ctx context.Context, client *github.Client, owner, repo string, pullNumber int) (*mcp.CallToolResult, error) {
Expand Down
172 changes: 172 additions & 0 deletions pkg/github/pullrequests_batch_metadata.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
package github

import (
"context"
"fmt"

"github.com/github/github-mcp-server/pkg/ifc"
"github.com/github/github-mcp-server/pkg/inventory"
"github.com/github/github-mcp-server/pkg/translations"
"github.com/github/github-mcp-server/pkg/utils"
"github.com/google/go-github/v87/github"
"github.com/google/jsonschema-go/jsonschema"
"github.com/modelcontextprotocol/go-sdk/mcp"

"github.com/github/github-mcp-server/pkg/scopes"
)

const maxPullRequestMetadataBatchSize = 25

type batchPullRequestMetadataError struct {
PullNumber int `json:"pull_number"`
Message string `json:"message"`
}

type batchPullRequestMetadataResponse struct {
PullRequests []MinimalPullRequest `json:"pull_requests"`
Errors []batchPullRequestMetadataError `json:"errors,omitempty"`
}

func GetPullRequestMetadataBatch(t translations.TranslationHelperFunc) inventory.ServerTool {
schema := &jsonschema.Schema{
Type: "object",
Properties: map[string]*jsonschema.Schema{
"owner": {
Type: "string",
Description: "Repository owner",
},
"repo": {
Type: "string",
Description: "Repository name",
},
"pullNumbers": {
Type: "array",
Description: fmt.Sprintf("Explicit pull request numbers to hydrate. Accepts up to %d items.", maxPullRequestMetadataBatchSize),
MinItems: jsonschema.Ptr(1),
MaxItems: jsonschema.Ptr(maxPullRequestMetadataBatchSize),
Items: &jsonschema.Schema{
Type: "integer",
Minimum: jsonschema.Ptr(1.0),
},
},
},
Required: []string{"owner", "repo", "pullNumbers"},
}

return NewTool(
ToolsetMetadataPullRequests,
mcp.Tool{
Name: "get_pull_request_metadata_batch",
Description: t("TOOL_GET_PULL_REQUEST_METADATA_BATCH_DESCRIPTION", "Get metadata for an explicit list of pull requests in a GitHub repository. Returns partial success with per-PR errors when some requested pull requests cannot be hydrated."),
Annotations: &mcp.ToolAnnotations{
Title: t("TOOL_GET_PULL_REQUEST_METADATA_BATCH_USER_TITLE", "Get batch pull request metadata"),
ReadOnlyHint: true,
},
InputSchema: schema,
},
[]scopes.Scope{scopes.Repo},
func(ctx context.Context, deps ToolDependencies, _ *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
}
pullNumbers, err := requiredPullNumberBatchParam(args, "pullNumbers", maxPullRequestMetadataBatchSize)
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}

client, err := deps.GetClient(ctx)
if err != nil {
return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil
}

attachIFC := func(r *mcp.CallToolResult) *mcp.CallToolResult {
return attachRepoVisibilityIFCLabel(ctx, deps, client, owner, repo, r, ifc.LabelListIssues)
}

result := batchPullRequestMetadataResponse{
PullRequests: make([]MinimalPullRequest, 0, len(pullNumbers)),
Errors: make([]batchPullRequestMetadataError, 0),
}

for _, pullNumber := range pullNumbers {
pr, err := fetchMinimalPullRequest(ctx, client, deps, owner, repo, pullNumber)
if err != nil {
result.Errors = append(result.Errors, batchPullRequestMetadataError{
PullNumber: pullNumber,
Message: err.Error(),
})
continue
}

result.PullRequests = append(result.PullRequests, pr)
}

return attachIFC(MarshalledTextResult(result)), nil, nil
},
)
}

func requiredPullNumberBatchParam(args map[string]any, key string, maxItems int) ([]int, error) {
raw, ok := args[key]
if !ok {
return nil, fmt.Errorf("missing required parameter: %s", key)
}

values, ok := raw.([]any)
if !ok {
return nil, fmt.Errorf("parameter %s could not be coerced to []int, is %T", key, raw)
}
if len(values) == 0 {
return nil, fmt.Errorf("parameter %s must contain at least one pull request number", key)
}
if len(values) > maxItems {
return nil, fmt.Errorf("parameter %s exceeds the maximum batch size of %d", key, maxItems)
}

pullNumbers := make([]int, 0, len(values))
seen := make(map[int]struct{}, len(values))
for i, value := range values {
number, ok := value.(float64)
if !ok {
return nil, fmt.Errorf("parameter %s element %d is not a number, is %T", key, i, value)
}
if number < 1 || number != float64(int(number)) {
return nil, fmt.Errorf("parameter %s element %d must be a positive integer", key, i)
}
intNumber := int(number)
if _, ok := seen[intNumber]; ok {
continue
}
seen[intNumber] = struct{}{}
pullNumbers = append(pullNumbers, intNumber)
}

return pullNumbers, nil
}

func fetchMinimalPullRequest(ctx context.Context, client *github.Client, deps ToolDependencies, owner, repo string, pullNumber int) (MinimalPullRequest, error) {
minimalPR, toolErr, err := getMinimalPullRequest(ctx, client, deps, owner, repo, pullNumber)
if toolErr != nil {
return MinimalPullRequest{}, fmt.Errorf("%s", getErrorResultText(toolErr))
}
if err != nil {
return MinimalPullRequest{}, err
}
return minimalPR, nil
}

func getErrorResultText(result *mcp.CallToolResult) string {
if result == nil || len(result.Content) == 0 {
return "failed to get pull request"
}
text, ok := result.Content[0].(*mcp.TextContent)
if !ok {
return "failed to get pull request"
}
return text.Text
}
Loading