Skip to content

Commit be5dfb0

Browse files
Reject unsupported pagination params in pull_request_read
The pull_request_read tool exposes two pagination mechanisms: page/perPage for the REST-backed methods and the after cursor for get_review_comments. Passing a parameter the selected method does not use was silently dropped, so callers (especially LLM agents) would receive the first page again with no signal that anything was ignored. Validate the pagination parameters against the selected method before dispatch and return a tool error naming the method and the parameter: - after is rejected for all methods except get_review_comments - page is rejected for get_review_comments - perPage remains accepted for every method Fixes #3316
1 parent 85598ba commit be5dfb0

2 files changed

Lines changed: 181 additions & 0 deletions

File tree

‎pkg/github/pullrequests.go‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,9 @@ Possible options:
9797
if err != nil {
9898
return utils.NewToolResultError(err.Error()), nil, nil
9999
}
100+
if err := validatePaginationParamsForMethod(method, args); err != nil {
101+
return utils.NewToolResultError(err.Error()), nil, nil
102+
}
100103
pagination, err := OptionalPaginationParams(args)
101104
if err != nil {
102105
return utils.NewToolResultError(err.Error()), nil, nil
@@ -159,6 +162,22 @@ Possible options:
159162
})
160163
}
161164

165+
// validatePaginationParamsForMethod rejects pagination parameters that the
166+
// selected pull_request_read method does not use. get_review_comments uses
167+
// cursor-based pagination (perPage, after), while the other methods use
168+
// page/perPage. perPage applies to every method. Without this check, a
169+
// mismatched parameter is silently dropped and the caller receives the
170+
// first page again with no indication that the parameter was ignored.
171+
func validatePaginationParamsForMethod(method string, args map[string]any) error {
172+
if _, hasAfter := args["after"]; hasAfter && method != "get_review_comments" {
173+
return fmt.Errorf("method %q uses page/perPage pagination; \"after\" is not supported. Use method %q for cursor-based pagination", method, "get_review_comments")
174+
}
175+
if _, hasPage := args["page"]; hasPage && method == "get_review_comments" {
176+
return fmt.Errorf("method %q uses cursor-based pagination (perPage, after); \"page\" is not supported", method)
177+
}
178+
return nil
179+
}
180+
162181
func GetPullRequest(ctx context.Context, client *github.Client, deps ToolDependencies, owner, repo string, pullNumber int) (*mcp.CallToolResult, error) {
163182
cache, err := deps.GetRepoAccessCache(ctx)
164183
if err != nil {

‎pkg/github/pullrequests_test.go‎

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4324,6 +4324,168 @@ func getLatestPendingReviewQuery(p getLatestPendingReviewQueryParams) githubv4mo
43244324
)
43254325
}
43264326

4327+
func Test_PullRequestRead_UnsupportedPaginationParams(t *testing.T) {
4328+
// Verify tool definition once
4329+
serverTool := PullRequestRead(translations.NullTranslationHelper)
4330+
tool := serverTool.Tool
4331+
require.NoError(t, toolsnaps.Test(tool.Name, tool))
4332+
4333+
mockFiles := []*github.CommitFile{
4334+
{
4335+
Filename: github.Ptr("file1.go"),
4336+
Status: github.Ptr("modified"),
4337+
Additions: github.Ptr(10),
4338+
Deletions: github.Ptr(5),
4339+
Changes: github.Ptr(15),
4340+
Patch: github.Ptr("@@ -1,5 +1,10 @@"),
4341+
},
4342+
}
4343+
4344+
tests := []struct {
4345+
name string
4346+
mockedClient *http.Client
4347+
gqlHTTPClient *http.Client
4348+
requestArgs map[string]any
4349+
expectError bool
4350+
expectedErrMsg string
4351+
}{
4352+
{
4353+
name: "after is rejected for get_files",
4354+
requestArgs: map[string]any{
4355+
"method": "get_files",
4356+
"owner": "owner",
4357+
"repo": "repo",
4358+
"pullNumber": float64(42),
4359+
"after": "cursor-page-2",
4360+
},
4361+
expectError: true,
4362+
expectedErrMsg: `method "get_files" uses page/perPage pagination; "after" is not supported`,
4363+
},
4364+
{
4365+
name: "after is rejected for get",
4366+
requestArgs: map[string]any{
4367+
"method": "get",
4368+
"owner": "owner",
4369+
"repo": "repo",
4370+
"pullNumber": float64(42),
4371+
"after": "cursor-page-2",
4372+
},
4373+
expectError: true,
4374+
expectedErrMsg: `method "get" uses page/perPage pagination; "after" is not supported`,
4375+
},
4376+
{
4377+
name: "page is rejected for get_review_comments",
4378+
requestArgs: map[string]any{
4379+
"method": "get_review_comments",
4380+
"owner": "owner",
4381+
"repo": "repo",
4382+
"pullNumber": float64(42),
4383+
"page": float64(2),
4384+
},
4385+
expectError: true,
4386+
expectedErrMsg: `method "get_review_comments" uses cursor-based pagination (perPage, after); "page" is not supported`,
4387+
},
4388+
{
4389+
name: "page and perPage are accepted for get_files",
4390+
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
4391+
GetReposPullsFilesByOwnerByRepoByPullNumber: expectQueryParams(t, map[string]string{
4392+
"page": "2",
4393+
"per_page": "10",
4394+
}).andThen(
4395+
mockResponse(t, http.StatusOK, mockFiles),
4396+
),
4397+
}),
4398+
requestArgs: map[string]any{
4399+
"method": "get_files",
4400+
"owner": "owner",
4401+
"repo": "repo",
4402+
"pullNumber": float64(42),
4403+
"page": float64(2),
4404+
"perPage": float64(10),
4405+
},
4406+
expectError: false,
4407+
},
4408+
{
4409+
name: "perPage and after are accepted for get_review_comments",
4410+
gqlHTTPClient: githubv4mock.NewMockedHTTPClient(
4411+
githubv4mock.NewQueryMatcher(
4412+
reviewThreadsQuery{},
4413+
map[string]any{
4414+
"owner": githubv4.String("owner"),
4415+
"repo": githubv4.String("repo"),
4416+
"prNum": githubv4.Int(42),
4417+
"first": githubv4.Int(10),
4418+
"commentsPerThread": githubv4.Int(100),
4419+
"after": githubv4.String("cursor-page-2"),
4420+
},
4421+
githubv4mock.DataResponse(map[string]any{
4422+
"repository": map[string]any{
4423+
"pullRequest": map[string]any{
4424+
"reviewThreads": map[string]any{
4425+
"nodes": []map[string]any{},
4426+
"pageInfo": map[string]any{
4427+
"hasNextPage": false,
4428+
"hasPreviousPage": true,
4429+
"startCursor": "cursor3",
4430+
"endCursor": "cursor4",
4431+
},
4432+
"totalCount": 5,
4433+
},
4434+
},
4435+
},
4436+
}),
4437+
),
4438+
),
4439+
requestArgs: map[string]any{
4440+
"method": "get_review_comments",
4441+
"owner": "owner",
4442+
"repo": "repo",
4443+
"pullNumber": float64(42),
4444+
"perPage": float64(10),
4445+
"after": "cursor-page-2",
4446+
},
4447+
expectError: false,
4448+
},
4449+
}
4450+
4451+
for _, tc := range tests {
4452+
t.Run(tc.name, func(t *testing.T) {
4453+
var gqlClient *githubv4.Client
4454+
if tc.gqlHTTPClient != nil {
4455+
gqlClient = githubv4.NewClient(tc.gqlHTTPClient)
4456+
} else {
4457+
gqlClient = githubv4.NewClient(nil)
4458+
}
4459+
4460+
deps := BaseDeps{
4461+
Client: mustNewGHClient(t, tc.mockedClient),
4462+
GQLClient: gqlClient,
4463+
RepoAccessCache: stubRepoAccessCache(nil, 5*time.Minute),
4464+
Flags: stubFeatureFlags(nil),
4465+
}
4466+
handler := serverTool.Handler(deps)
4467+
4468+
// Create call request
4469+
request := createMCPRequest(tc.requestArgs)
4470+
4471+
// Call handler
4472+
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
4473+
4474+
// Verify results
4475+
if tc.expectError {
4476+
require.NoError(t, err)
4477+
require.True(t, result.IsError)
4478+
errorContent := getErrorResult(t, result)
4479+
assert.Contains(t, errorContent.Text, tc.expectedErrMsg)
4480+
return
4481+
}
4482+
4483+
require.NoError(t, err)
4484+
require.False(t, result.IsError)
4485+
})
4486+
}
4487+
}
4488+
43274489
func TestAddReplyToPullRequestComment(t *testing.T) {
43284490
t.Parallel()
43294491

0 commit comments

Comments
 (0)