diff --git a/README.md b/README.md index 2e896cea8..8eb7aa521 100644 --- a/README.md +++ b/README.md @@ -468,35 +468,31 @@ The following sets of tools are available: - `run_id`: The unique identifier of the workflow run (number, required) - **list_workflow_jobs** - List workflow jobs + - `cursor`: Cursor for pagination. Use the cursor value from the previous response's pagination metadata to retrieve the next page. Leave blank for the first page. (string, optional) - `filter`: Filters jobs by their completed_at timestamp (string, optional) - `owner`: Repository owner (string, required) - - `page`: Page number for pagination (min 1) (number, optional) - - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) - `repo`: Repository name (string, required) - `run_id`: The unique identifier of the workflow run (number, required) - **list_workflow_run_artifacts** - List workflow artifacts + - `cursor`: Cursor for pagination. Use the cursor value from the previous response's pagination metadata to retrieve the next page. Leave blank for the first page. (string, optional) - `owner`: Repository owner (string, required) - - `page`: Page number for pagination (min 1) (number, optional) - - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) - `repo`: Repository name (string, required) - `run_id`: The unique identifier of the workflow run (number, required) - **list_workflow_runs** - List workflow runs - `actor`: Returns someone's workflow runs. Use the login for the user who created the workflow run. (string, optional) - `branch`: Returns workflow runs associated with a branch. Use the name of the branch. (string, optional) + - `cursor`: Cursor for pagination. Use the cursor value from the previous response's pagination metadata to retrieve the next page. Leave blank for the first page. (string, optional) - `event`: Returns workflow runs for a specific event type (string, optional) - `owner`: Repository owner (string, required) - - `page`: Page number for pagination (min 1) (number, optional) - - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) - `repo`: Repository name (string, required) - `status`: Returns workflow runs with the check run status (string, optional) - `workflow_id`: The workflow ID or workflow file name (string, required) - **list_workflows** - List workflows + - `cursor`: Cursor for pagination. Use the cursor value from the previous response's pagination metadata to retrieve the next page. Leave blank for the first page. (string, optional) - `owner`: Repository owner (string, required) - - `page`: Page number for pagination (min 1) (number, optional) - - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) - `repo`: Repository name (string, required) - **rerun_failed_jobs** - Rerun failed jobs @@ -615,8 +611,7 @@ The following sets of tools are available: - `gist_id`: The ID of the gist (string, required) - **list_gists** - List Gists - - `page`: Page number for pagination (min 1) (number, optional) - - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) + - `cursor`: Cursor for pagination. Use the cursor value from the previous response's pagination metadata to retrieve the next page. Leave blank for the first page. (string, optional) - `since`: Only gists updated after this time (ISO 8601 timestamp) (string, optional) - `username`: GitHub username (omit for authenticated user's gists) (string, optional) @@ -649,6 +644,7 @@ The following sets of tools are available: - `repo`: Repository name (string, required) - **issue_read** - Get issue details + - `cursor`: Cursor for pagination. Use the cursor value from the previous response's pagination metadata to retrieve the next page. Leave blank for the first page. (string, optional) - `issue_number`: The number of the issue (number, required) - `method`: The read operation to perform on a single issue. Options are: @@ -658,8 +654,6 @@ Options are: 4. get_labels - Get labels assigned to the issue. (string, required) - `owner`: The owner of the repository (string, required) - - `page`: Page number for pagination (min 1) (number, optional) - - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) - `repo`: The name of the repository (string, required) - **issue_write** - Create or update issue. @@ -696,10 +690,9 @@ Options are: - `state`: Filter by state, by default both open and closed issues are returned when not provided (string, optional) - **search_issues** - Search issues + - `cursor`: Cursor for pagination. Use the cursor value from the previous response's pagination metadata to retrieve the next page. Leave blank for the first page. (string, optional) - `order`: Sort order (string, optional) - `owner`: Optional repository owner. If provided with repo, only issues for this repository are listed. (string, optional) - - `page`: Page number for pagination (min 1) (number, optional) - - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) - `query`: Search query using GitHub issues search syntax (string, required) - `repo`: Optional repository name. If provided with owner, only issues for this repository are listed. (string, optional) - `sort`: Sort field by number of matches of categories, defaults to best match (string, optional) @@ -758,10 +751,9 @@ Options are: - **list_notifications** - List notifications - `before`: Only show notifications updated before the given time (ISO 8601 format) (string, optional) + - `cursor`: Cursor for pagination. Use the cursor value from the previous response's pagination metadata to retrieve the next page. Leave blank for the first page. (string, optional) - `filter`: Filter notifications to, use default unless specified. Read notifications are ones that have already been acknowledged by the user. Participating notifications are those that the user is directly involved in, such as issues or pull requests they have commented on or created. (string, optional) - `owner`: Optional repository owner. If provided with repo, only notifications for this repository are listed. (string, optional) - - `page`: Page number for pagination (min 1) (number, optional) - - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) - `repo`: Optional repository name. If provided with owner, only notifications for this repository are listed. (string, optional) - `since`: Only show notifications updated after the given time (ISO 8601 format) (string, optional) @@ -786,9 +778,8 @@ Options are: Organizations - **search_orgs** - Search organizations + - `cursor`: Cursor for pagination. Use the cursor value from the previous response's pagination metadata to retrieve the next page. Leave blank for the first page. (string, optional) - `order`: Sort order (string, optional) - - `page`: Page number for pagination (min 1) (number, optional) - - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) - `query`: Organization search query. Examples: 'microsoft', 'location:california', 'created:>=2025-01-01'. Search is automatically scoped to type:org. (string, required) - `sort`: Sort field by category (string, optional) @@ -886,11 +877,10 @@ Options are: - **list_pull_requests** - List pull requests - `base`: Filter by base branch (string, optional) + - `cursor`: Cursor for pagination. Use the cursor value from the previous response's pagination metadata to retrieve the next page. Leave blank for the first page. (string, optional) - `direction`: Sort direction (string, optional) - `head`: Filter by head user/org and branch (string, optional) - `owner`: Repository owner (string, required) - - `page`: Page number for pagination (min 1) (number, optional) - - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) - `repo`: Repository name (string, required) - `sort`: Sort by (string, optional) - `state`: Filter by state (string, optional) @@ -904,6 +894,7 @@ Options are: - `repo`: Repository name (string, required) - **pull_request_read** - Get details for a single pull request + - `cursor`: Cursor for pagination. Use the cursor value from the previous response's pagination metadata to retrieve the next page. Leave blank for the first page. (string, optional) - `method`: Action to specify what pull request data needs to be retrieved from GitHub. Possible options: 1. get - Get details of a specific pull request. @@ -915,8 +906,6 @@ Possible options: 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. (string, required) - `owner`: Repository owner (string, required) - - `page`: Page number for pagination (min 1) (number, optional) - - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) - `pullNumber`: Pull request number (number, required) - `repo`: Repository name (string, required) @@ -935,10 +924,9 @@ Possible options: - `repo`: Repository name (string, required) - **search_pull_requests** - Search pull requests + - `cursor`: Cursor for pagination. Use the cursor value from the previous response's pagination metadata to retrieve the next page. Leave blank for the first page. (string, optional) - `order`: Sort order (string, optional) - `owner`: Optional repository owner. If provided with repo, only pull requests for this repository are listed. (string, optional) - - `page`: Page number for pagination (min 1) (number, optional) - - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) - `query`: Search query using GitHub pull request search syntax (string, required) - `repo`: Optional repository name. If provided with owner, only pull requests for this repository are listed. (string, optional) - `sort`: Sort field by number of matches of categories, defaults to best match (string, optional) @@ -1002,10 +990,9 @@ Possible options: - `repo`: Repository name (string, required) - **get_commit** - Get commit details + - `cursor`: Cursor for pagination. Use the cursor value from the previous response's pagination metadata to retrieve the next page. Leave blank for the first page. (string, optional) - `include_diff`: Whether to include file diffs and stats in the response. Default is true. (boolean, optional) - `owner`: Repository owner (string, required) - - `page`: Page number for pagination (min 1) (number, optional) - - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) - `repo`: Repository name (string, required) - `sha`: Commit SHA, branch name, or tag name (string, required) @@ -1031,29 +1018,25 @@ Possible options: - `tag`: Tag name (string, required) - **list_branches** - List branches + - `cursor`: Cursor for pagination. Use the cursor value from the previous response's pagination metadata to retrieve the next page. Leave blank for the first page. (string, optional) - `owner`: Repository owner (string, required) - - `page`: Page number for pagination (min 1) (number, optional) - - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) - `repo`: Repository name (string, required) - **list_commits** - List commits - `author`: Author username or email address to filter commits by (string, optional) + - `cursor`: Cursor for pagination. Use the cursor value from the previous response's pagination metadata to retrieve the next page. Leave blank for the first page. (string, optional) - `owner`: Repository owner (string, required) - - `page`: Page number for pagination (min 1) (number, optional) - - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) - `repo`: Repository name (string, required) - `sha`: Commit SHA, branch or tag name to list commits of. If not provided, uses the default branch of the repository. If a commit SHA is provided, will list commits up to that SHA. (string, optional) - **list_releases** - List releases + - `cursor`: Cursor for pagination. Use the cursor value from the previous response's pagination metadata to retrieve the next page. Leave blank for the first page. (string, optional) - `owner`: Repository owner (string, required) - - `page`: Page number for pagination (min 1) (number, optional) - - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) - `repo`: Repository name (string, required) - **list_tags** - List tags + - `cursor`: Cursor for pagination. Use the cursor value from the previous response's pagination metadata to retrieve the next page. Leave blank for the first page. (string, optional) - `owner`: Repository owner (string, required) - - `page`: Page number for pagination (min 1) (number, optional) - - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) - `repo`: Repository name (string, required) - **push_files** - Push files to repository @@ -1064,17 +1047,15 @@ Possible options: - `repo`: Repository name (string, required) - **search_code** - Search code + - `cursor`: Cursor for pagination. Use the cursor value from the previous response's pagination metadata to retrieve the next page. Leave blank for the first page. (string, optional) - `order`: Sort order for results (string, optional) - - `page`: Page number for pagination (min 1) (number, optional) - - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) - `query`: 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. (string, required) - `sort`: Sort field ('indexed' only) (string, optional) - **search_repositories** - Search repositories + - `cursor`: Cursor for pagination. Use the cursor value from the previous response's pagination metadata to retrieve the next page. Leave blank for the first page. (string, optional) - `minimal_output`: Return minimal repository information (default: true). When false, returns full GitHub API repository objects. (boolean, optional) - `order`: Sort order (string, optional) - - `page`: Page number for pagination (min 1) (number, optional) - - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) - `query`: Repository search query. Examples: 'machine learning in:name stars:>1000 language:python', 'topic:react', 'user:facebook'. Supports advanced search syntax for precise filtering. (string, required) - `sort`: Sort repositories by field, defaults to best match (string, optional) @@ -1138,9 +1119,8 @@ Possible options: Stargazers - **list_starred_repositories** - List starred repositories + - `cursor`: Cursor for pagination. Use the cursor value from the previous response's pagination metadata to retrieve the next page. Leave blank for the first page. (string, optional) - `direction`: The direction to sort the results by. (string, optional) - - `page`: Page number for pagination (min 1) (number, optional) - - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) - `sort`: How to sort the results. Can be either 'created' (when the repository was starred) or 'updated' (when the repository was last pushed to). (string, optional) - `username`: Username to list starred repositories for. Defaults to the authenticated user. (string, optional) @@ -1159,9 +1139,8 @@ Possible options: Users - **search_users** - Search users + - `cursor`: Cursor for pagination. Use the cursor value from the previous response's pagination metadata to retrieve the next page. Leave blank for the first page. (string, optional) - `order`: Sort order (string, optional) - - `page`: Page number for pagination (min 1) (number, optional) - - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) - `query`: User search query. Examples: 'john smith', 'location:seattle', 'followers:>100'. Search is automatically scoped to type:user. (string, required) - `sort`: Sort users by number of followers or repositories, or when the person joined GitHub. (string, optional) diff --git a/pkg/github/__toolsnaps__/get_commit.snap b/pkg/github/__toolsnaps__/get_commit.snap index 1c2ecc9a3..55bdbbaaa 100644 --- a/pkg/github/__toolsnaps__/get_commit.snap +++ b/pkg/github/__toolsnaps__/get_commit.snap @@ -6,6 +6,10 @@ "description": "Get details for a commit from a GitHub repository", "inputSchema": { "properties": { + "cursor": { + "description": "Cursor for pagination. Use the cursor value from the previous response's pagination metadata to retrieve the next page. Leave blank for the first page.", + "type": "string" + }, "include_diff": { "default": true, "description": "Whether to include file diffs and stats in the response. Default is true.", @@ -15,17 +19,6 @@ "description": "Repository owner", "type": "string" }, - "page": { - "description": "Page number for pagination (min 1)", - "minimum": 1, - "type": "number" - }, - "perPage": { - "description": "Results per page for pagination (min 1, max 100)", - "maximum": 100, - "minimum": 1, - "type": "number" - }, "repo": { "description": "Repository name", "type": "string" diff --git a/pkg/github/__toolsnaps__/issue_read.snap b/pkg/github/__toolsnaps__/issue_read.snap index 9e9462df6..450d0e1f1 100644 --- a/pkg/github/__toolsnaps__/issue_read.snap +++ b/pkg/github/__toolsnaps__/issue_read.snap @@ -6,6 +6,10 @@ "description": "Get information about a specific issue in a GitHub repository.", "inputSchema": { "properties": { + "cursor": { + "description": "Cursor for pagination. Use the cursor value from the previous response's pagination metadata to retrieve the next page. Leave blank for the first page.", + "type": "string" + }, "issue_number": { "description": "The number of the issue", "type": "number" @@ -24,17 +28,6 @@ "description": "The owner of the repository", "type": "string" }, - "page": { - "description": "Page number for pagination (min 1)", - "minimum": 1, - "type": "number" - }, - "perPage": { - "description": "Results per page for pagination (min 1, max 100)", - "maximum": 100, - "minimum": 1, - "type": "number" - }, "repo": { "description": "The name of the repository", "type": "string" diff --git a/pkg/github/__toolsnaps__/list_branches.snap b/pkg/github/__toolsnaps__/list_branches.snap index 492b6d527..f5daa61bf 100644 --- a/pkg/github/__toolsnaps__/list_branches.snap +++ b/pkg/github/__toolsnaps__/list_branches.snap @@ -6,21 +6,14 @@ "description": "List branches in a GitHub repository", "inputSchema": { "properties": { + "cursor": { + "description": "Cursor for pagination. Use the cursor value from the previous response's pagination metadata to retrieve the next page. Leave blank for the first page.", + "type": "string" + }, "owner": { "description": "Repository owner", "type": "string" }, - "page": { - "description": "Page number for pagination (min 1)", - "minimum": 1, - "type": "number" - }, - "perPage": { - "description": "Results per page for pagination (min 1, max 100)", - "maximum": 100, - "minimum": 1, - "type": "number" - }, "repo": { "description": "Repository name", "type": "string" diff --git a/pkg/github/__toolsnaps__/list_commits.snap b/pkg/github/__toolsnaps__/list_commits.snap index a802436c2..176a519d1 100644 --- a/pkg/github/__toolsnaps__/list_commits.snap +++ b/pkg/github/__toolsnaps__/list_commits.snap @@ -10,21 +10,14 @@ "description": "Author username or email address to filter commits by", "type": "string" }, + "cursor": { + "description": "Cursor for pagination. Use the cursor value from the previous response's pagination metadata to retrieve the next page. Leave blank for the first page.", + "type": "string" + }, "owner": { "description": "Repository owner", "type": "string" }, - "page": { - "description": "Page number for pagination (min 1)", - "minimum": 1, - "type": "number" - }, - "perPage": { - "description": "Results per page for pagination (min 1, max 100)", - "maximum": 100, - "minimum": 1, - "type": "number" - }, "repo": { "description": "Repository name", "type": "string" diff --git a/pkg/github/__toolsnaps__/list_notifications.snap b/pkg/github/__toolsnaps__/list_notifications.snap index 92f25eb4c..24b1902a0 100644 --- a/pkg/github/__toolsnaps__/list_notifications.snap +++ b/pkg/github/__toolsnaps__/list_notifications.snap @@ -10,6 +10,10 @@ "description": "Only show notifications updated before the given time (ISO 8601 format)", "type": "string" }, + "cursor": { + "description": "Cursor for pagination. Use the cursor value from the previous response's pagination metadata to retrieve the next page. Leave blank for the first page.", + "type": "string" + }, "filter": { "description": "Filter notifications to, use default unless specified. Read notifications are ones that have already been acknowledged by the user. Participating notifications are those that the user is directly involved in, such as issues or pull requests they have commented on or created.", "enum": [ @@ -23,17 +27,6 @@ "description": "Optional repository owner. If provided with repo, only notifications for this repository are listed.", "type": "string" }, - "page": { - "description": "Page number for pagination (min 1)", - "minimum": 1, - "type": "number" - }, - "perPage": { - "description": "Results per page for pagination (min 1, max 100)", - "maximum": 100, - "minimum": 1, - "type": "number" - }, "repo": { "description": "Optional repository name. If provided with owner, only notifications for this repository are listed.", "type": "string" diff --git a/pkg/github/__toolsnaps__/list_pull_requests.snap b/pkg/github/__toolsnaps__/list_pull_requests.snap index fee7e2ff1..d468378af 100644 --- a/pkg/github/__toolsnaps__/list_pull_requests.snap +++ b/pkg/github/__toolsnaps__/list_pull_requests.snap @@ -10,6 +10,10 @@ "description": "Filter by base branch", "type": "string" }, + "cursor": { + "description": "Cursor for pagination. Use the cursor value from the previous response's pagination metadata to retrieve the next page. Leave blank for the first page.", + "type": "string" + }, "direction": { "description": "Sort direction", "enum": [ @@ -26,17 +30,6 @@ "description": "Repository owner", "type": "string" }, - "page": { - "description": "Page number for pagination (min 1)", - "minimum": 1, - "type": "number" - }, - "perPage": { - "description": "Results per page for pagination (min 1, max 100)", - "maximum": 100, - "minimum": 1, - "type": "number" - }, "repo": { "description": "Repository name", "type": "string" diff --git a/pkg/github/__toolsnaps__/list_starred_repositories.snap b/pkg/github/__toolsnaps__/list_starred_repositories.snap index b02563ae2..735c71ed7 100644 --- a/pkg/github/__toolsnaps__/list_starred_repositories.snap +++ b/pkg/github/__toolsnaps__/list_starred_repositories.snap @@ -6,6 +6,10 @@ "description": "List starred repositories", "inputSchema": { "properties": { + "cursor": { + "description": "Cursor for pagination. Use the cursor value from the previous response's pagination metadata to retrieve the next page. Leave blank for the first page.", + "type": "string" + }, "direction": { "description": "The direction to sort the results by.", "enum": [ @@ -14,17 +18,6 @@ ], "type": "string" }, - "page": { - "description": "Page number for pagination (min 1)", - "minimum": 1, - "type": "number" - }, - "perPage": { - "description": "Results per page for pagination (min 1, max 100)", - "maximum": 100, - "minimum": 1, - "type": "number" - }, "sort": { "description": "How to sort the results. Can be either 'created' (when the repository was starred) or 'updated' (when the repository was last pushed to).", "enum": [ diff --git a/pkg/github/__toolsnaps__/list_tags.snap b/pkg/github/__toolsnaps__/list_tags.snap index fcb9853fd..09dee8ce3 100644 --- a/pkg/github/__toolsnaps__/list_tags.snap +++ b/pkg/github/__toolsnaps__/list_tags.snap @@ -6,21 +6,14 @@ "description": "List git tags in a GitHub repository", "inputSchema": { "properties": { + "cursor": { + "description": "Cursor for pagination. Use the cursor value from the previous response's pagination metadata to retrieve the next page. Leave blank for the first page.", + "type": "string" + }, "owner": { "description": "Repository owner", "type": "string" }, - "page": { - "description": "Page number for pagination (min 1)", - "minimum": 1, - "type": "number" - }, - "perPage": { - "description": "Results per page for pagination (min 1, max 100)", - "maximum": 100, - "minimum": 1, - "type": "number" - }, "repo": { "description": "Repository name", "type": "string" diff --git a/pkg/github/__toolsnaps__/pull_request_read.snap b/pkg/github/__toolsnaps__/pull_request_read.snap index be9661aae..1d179e484 100644 --- a/pkg/github/__toolsnaps__/pull_request_read.snap +++ b/pkg/github/__toolsnaps__/pull_request_read.snap @@ -6,6 +6,10 @@ "description": "Get information on a specific pull request in GitHub repository.", "inputSchema": { "properties": { + "cursor": { + "description": "Cursor for pagination. Use the cursor value from the previous response's pagination metadata to retrieve the next page. Leave blank for the first page.", + "type": "string" + }, "method": { "description": "Action to specify what pull request data needs to be retrieved from GitHub. \nPossible options: \n 1. get - Get details of a specific pull request.\n 2. get_diff - Get the diff of a pull request.\n 3. get_status - Get status of a head commit in a pull request. This reflects status of builds and checks.\n 4. get_files - Get the list of files changed in a pull request. Use with pagination parameters to control the number of results returned.\n 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.\n 6. get_reviews - Get the reviews on a pull request. When asked for review comments, use get_review_comments method.\n 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.\n", "enum": [ @@ -23,17 +27,6 @@ "description": "Repository owner", "type": "string" }, - "page": { - "description": "Page number for pagination (min 1)", - "minimum": 1, - "type": "number" - }, - "perPage": { - "description": "Results per page for pagination (min 1, max 100)", - "maximum": 100, - "minimum": 1, - "type": "number" - }, "pullNumber": { "description": "Pull request number", "type": "number" diff --git a/pkg/github/__toolsnaps__/search_code.snap b/pkg/github/__toolsnaps__/search_code.snap index 4ef40c5f8..fc9d8776b 100644 --- a/pkg/github/__toolsnaps__/search_code.snap +++ b/pkg/github/__toolsnaps__/search_code.snap @@ -6,6 +6,10 @@ "description": "Fast and precise code search across ALL GitHub repositories using GitHub's native search engine. Best for finding exact symbols, functions, classes, or specific code patterns.", "inputSchema": { "properties": { + "cursor": { + "description": "Cursor for pagination. Use the cursor value from the previous response's pagination metadata to retrieve the next page. Leave blank for the first page.", + "type": "string" + }, "order": { "description": "Sort order for results", "enum": [ @@ -14,17 +18,6 @@ ], "type": "string" }, - "page": { - "description": "Page number for pagination (min 1)", - "minimum": 1, - "type": "number" - }, - "perPage": { - "description": "Results per page for pagination (min 1, max 100)", - "maximum": 100, - "minimum": 1, - "type": "number" - }, "query": { "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.", "type": "string" diff --git a/pkg/github/__toolsnaps__/search_issues.snap b/pkg/github/__toolsnaps__/search_issues.snap index bf1982411..23c997c9a 100644 --- a/pkg/github/__toolsnaps__/search_issues.snap +++ b/pkg/github/__toolsnaps__/search_issues.snap @@ -6,6 +6,10 @@ "description": "Search for issues in GitHub repositories using issues search syntax already scoped to is:issue", "inputSchema": { "properties": { + "cursor": { + "description": "Cursor for pagination. Use the cursor value from the previous response's pagination metadata to retrieve the next page. Leave blank for the first page.", + "type": "string" + }, "order": { "description": "Sort order", "enum": [ @@ -18,17 +22,6 @@ "description": "Optional repository owner. If provided with repo, only issues for this repository are listed.", "type": "string" }, - "page": { - "description": "Page number for pagination (min 1)", - "minimum": 1, - "type": "number" - }, - "perPage": { - "description": "Results per page for pagination (min 1, max 100)", - "maximum": 100, - "minimum": 1, - "type": "number" - }, "query": { "description": "Search query using GitHub issues search syntax", "type": "string" diff --git a/pkg/github/__toolsnaps__/search_pull_requests.snap b/pkg/github/__toolsnaps__/search_pull_requests.snap index 811aa1322..7b0296276 100644 --- a/pkg/github/__toolsnaps__/search_pull_requests.snap +++ b/pkg/github/__toolsnaps__/search_pull_requests.snap @@ -6,6 +6,10 @@ "description": "Search for pull requests in GitHub repositories using issues search syntax already scoped to is:pr", "inputSchema": { "properties": { + "cursor": { + "description": "Cursor for pagination. Use the cursor value from the previous response's pagination metadata to retrieve the next page. Leave blank for the first page.", + "type": "string" + }, "order": { "description": "Sort order", "enum": [ @@ -18,17 +22,6 @@ "description": "Optional repository owner. If provided with repo, only pull requests for this repository are listed.", "type": "string" }, - "page": { - "description": "Page number for pagination (min 1)", - "minimum": 1, - "type": "number" - }, - "perPage": { - "description": "Results per page for pagination (min 1, max 100)", - "maximum": 100, - "minimum": 1, - "type": "number" - }, "query": { "description": "Search query using GitHub pull request search syntax", "type": "string" diff --git a/pkg/github/__toolsnaps__/search_repositories.snap b/pkg/github/__toolsnaps__/search_repositories.snap index 99828380e..a458b0875 100644 --- a/pkg/github/__toolsnaps__/search_repositories.snap +++ b/pkg/github/__toolsnaps__/search_repositories.snap @@ -6,6 +6,10 @@ "description": "Find GitHub repositories by name, description, readme, topics, or other metadata. Perfect for discovering projects, finding examples, or locating specific repositories across GitHub.", "inputSchema": { "properties": { + "cursor": { + "description": "Cursor for pagination. Use the cursor value from the previous response's pagination metadata to retrieve the next page. Leave blank for the first page.", + "type": "string" + }, "minimal_output": { "default": true, "description": "Return minimal repository information (default: true). When false, returns full GitHub API repository objects.", @@ -19,17 +23,6 @@ ], "type": "string" }, - "page": { - "description": "Page number for pagination (min 1)", - "minimum": 1, - "type": "number" - }, - "perPage": { - "description": "Results per page for pagination (min 1, max 100)", - "maximum": 100, - "minimum": 1, - "type": "number" - }, "query": { "description": "Repository search query. Examples: 'machine learning in:name stars:\u003e1000 language:python', 'topic:react', 'user:facebook'. Supports advanced search syntax for precise filtering.", "type": "string" diff --git a/pkg/github/__toolsnaps__/search_users.snap b/pkg/github/__toolsnaps__/search_users.snap index 73ff7a43c..da4d6a709 100644 --- a/pkg/github/__toolsnaps__/search_users.snap +++ b/pkg/github/__toolsnaps__/search_users.snap @@ -6,6 +6,10 @@ "description": "Find GitHub users by username, real name, or other profile information. Useful for locating developers, contributors, or team members.", "inputSchema": { "properties": { + "cursor": { + "description": "Cursor for pagination. Use the cursor value from the previous response's pagination metadata to retrieve the next page. Leave blank for the first page.", + "type": "string" + }, "order": { "description": "Sort order", "enum": [ @@ -14,17 +18,6 @@ ], "type": "string" }, - "page": { - "description": "Page number for pagination (min 1)", - "minimum": 1, - "type": "number" - }, - "perPage": { - "description": "Results per page for pagination (min 1, max 100)", - "maximum": 100, - "minimum": 1, - "type": "number" - }, "query": { "description": "User search query. Examples: 'john smith', 'location:seattle', 'followers:\u003e100'. Search is automatically scoped to type:user.", "type": "string" diff --git a/pkg/github/actions.go b/pkg/github/actions.go index 734109587..b8aa248ae 100644 --- a/pkg/github/actions.go +++ b/pkg/github/actions.go @@ -63,7 +63,7 @@ func ListWorkflows(getClient GetClientFn, t translations.TranslationHelperFunc) // Set up list options opts := &github.ListOptions{ - PerPage: pagination.PerPage, + PerPage: CursorFetchSize, // Fetch one extra to detect if more data exists Page: pagination.Page, } @@ -73,12 +73,7 @@ func ListWorkflows(getClient GetClientFn, t translations.TranslationHelperFunc) } 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 + return CreatePaginatedResponse(workflows, pagination.Page) } } @@ -201,7 +196,7 @@ func ListWorkflowRuns(getClient GetClientFn, t translations.TranslationHelperFun Event: event, Status: status, ListOptions: github.ListOptions{ - PerPage: pagination.PerPage, + PerPage: CursorFetchSize, // Fetch one extra to detect if more data exists Page: pagination.Page, }, } @@ -212,12 +207,7 @@ func ListWorkflowRuns(getClient GetClientFn, t translations.TranslationHelperFun } 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 + return CreatePaginatedResponse(workflowRuns, pagination.Page) } } @@ -504,7 +494,7 @@ func ListWorkflowJobs(getClient GetClientFn, t translations.TranslationHelperFun opts := &github.ListWorkflowJobsOptions{ Filter: filter, ListOptions: github.ListOptions{ - PerPage: pagination.PerPage, + PerPage: CursorFetchSize, // Fetch one extra to detect if more data exists Page: pagination.Page, }, } @@ -515,12 +505,23 @@ func ListWorkflowJobs(getClient GetClientFn, t translations.TranslationHelperFun } defer func() { _ = resp.Body.Close() }() - // Add optimization tip for failed job debugging + // Handle pagination and add optimization tip + hasMore := len(jobs.Jobs) > CursorPageSize + jobsToReturn := jobs.Jobs + if hasMore { + jobsToReturn = jobs.Jobs[:CursorPageSize] + } + response := map[string]any{ - "jobs": jobs, + "jobs": jobsToReturn, + "moreData": hasMore, "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", } + if hasMore { + response["cursor"] = EncodeCursor(pagination.Page + 1) + } + r, err := json.Marshal(response) if err != nil { return nil, fmt.Errorf("failed to marshal response: %w", err) @@ -1019,7 +1020,7 @@ func ListWorkflowRunArtifacts(getClient GetClientFn, t translations.TranslationH // Set up list options opts := &github.ListOptions{ - PerPage: pagination.PerPage, + PerPage: CursorFetchSize, // Fetch one extra to detect if more data exists Page: pagination.Page, } @@ -1029,12 +1030,7 @@ func ListWorkflowRunArtifacts(getClient GetClientFn, t translations.TranslationH } 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 + return CreatePaginatedResponse(artifacts, pagination.Page) } } diff --git a/pkg/github/actions_test.go b/pkg/github/actions_test.go index 04863ba1d..bf7a275f7 100644 --- a/pkg/github/actions_test.go +++ b/pkg/github/actions_test.go @@ -30,8 +30,7 @@ func Test_ListWorkflows(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, "perPage") - assert.Contains(t, tool.InputSchema.Properties, "page") + assert.Contains(t, tool.InputSchema.Properties, "cursor") assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo"}) tests := []struct { @@ -423,8 +422,7 @@ func Test_ListWorkflowRunArtifacts(t *testing.T) { 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, "perPage") - assert.Contains(t, tool.InputSchema.Properties, "page") + assert.Contains(t, tool.InputSchema.Properties, "cursor") assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "run_id"}) tests := []struct { diff --git a/pkg/github/gists.go b/pkg/github/gists.go index 47bfeb2bc..30f38340d 100644 --- a/pkg/github/gists.go +++ b/pkg/github/gists.go @@ -48,7 +48,7 @@ func ListGists(getClient GetClientFn, t translations.TranslationHelperFunc) (too opts := &github.GistListOptions{ ListOptions: github.ListOptions{ Page: pagination.Page, - PerPage: pagination.PerPage, + PerPage: CursorFetchSize, // Fetch one extra to detect if more data exists }, } @@ -80,12 +80,7 @@ func ListGists(getClient GetClientFn, t translations.TranslationHelperFunc) (too return mcp.NewToolResultError(fmt.Sprintf("failed to list gists: %s", string(body))), nil } - r, err := json.Marshal(gists) - if err != nil { - return nil, fmt.Errorf("failed to marshal response: %w", err) - } - - return mcp.NewToolResultText(string(r)), nil + return CreatePaginatedResponse(gists, pagination.Page) } } diff --git a/pkg/github/gists_test.go b/pkg/github/gists_test.go index c27578ff9..0a5314fca 100644 --- a/pkg/github/gists_test.go +++ b/pkg/github/gists_test.go @@ -23,8 +23,7 @@ func Test_ListGists(t *testing.T) { assert.NotEmpty(t, tool.Description) assert.Contains(t, tool.InputSchema.Properties, "username") assert.Contains(t, tool.InputSchema.Properties, "since") - assert.Contains(t, tool.InputSchema.Properties, "page") - assert.Contains(t, tool.InputSchema.Properties, "perPage") + assert.Contains(t, tool.InputSchema.Properties, "cursor") assert.Empty(t, tool.InputSchema.Required) // Setup mock gists for success case @@ -101,7 +100,7 @@ func Test_ListGists(t *testing.T) { expectQueryParams(t, map[string]string{ "since": "2023-01-01T00:00:00Z", "page": "2", - "per_page": "5", + "per_page": "11", }).andThen( mockResponse(t, http.StatusOK, mockGists), ), @@ -176,9 +175,11 @@ func Test_ListGists(t *testing.T) { // Parse the result and get the text content if no error textContent := getTextResult(t, result) - // Unmarshal and verify the result + // Extract items from paginated response + itemsBytes := extractItemsFromPaginatedResponse(t, textContent.Text) + var returnedGists []*github.Gist - err = json.Unmarshal([]byte(textContent.Text), &returnedGists) + err = json.Unmarshal(itemsBytes, &returnedGists) require.NoError(t, err) assert.Len(t, returnedGists, len(tc.expectedGists)) diff --git a/pkg/github/helper_test.go b/pkg/github/helper_test.go index bc1ae412f..4c05577fc 100644 --- a/pkg/github/helper_test.go +++ b/pkg/github/helper_test.go @@ -132,6 +132,23 @@ func getTextResult(t *testing.T, result *mcp.CallToolResult) mcp.TextContent { return textContent } +// extractItemsFromPaginatedResponse extracts the items array from a paginated response. +// If the response is not paginated (old format), it returns the original JSON. +func extractItemsFromPaginatedResponse(t *testing.T, jsonText string) []byte { + t.Helper() + var paginatedResponse PaginatedResponse + err := json.Unmarshal([]byte(jsonText), &paginatedResponse) + if err != nil { + // If unmarshaling fails, assume it's not a paginated response (old format) + return []byte(jsonText) + } + + // Extract items from paginated response + itemsBytes, err := json.Marshal(paginatedResponse.Items) + require.NoError(t, err) + return itemsBytes +} + func getErrorResult(t *testing.T, result *mcp.CallToolResult) mcp.TextContent { res := getTextResult(t, result) require.True(t, result.IsError, "expected tool call result to be an error") diff --git a/pkg/github/issues.go b/pkg/github/issues.go index 94f2f35e8..a266636bb 100644 --- a/pkg/github/issues.go +++ b/pkg/github/issues.go @@ -346,7 +346,7 @@ func GetIssueComments(ctx context.Context, client *github.Client, owner string, opts := &github.IssueListCommentsOptions{ ListOptions: github.ListOptions{ Page: pagination.Page, - PerPage: pagination.PerPage, + PerPage: CursorFetchSize, // Fetch one extra to detect if more data exists }, } @@ -364,19 +364,14 @@ func GetIssueComments(ctx context.Context, client *github.Client, owner string, return mcp.NewToolResultError(fmt.Sprintf("failed to get issue comments: %s", string(body))), nil } - r, err := json.Marshal(comments) - if err != nil { - return nil, fmt.Errorf("failed to marshal response: %w", err) - } - - return mcp.NewToolResultText(string(r)), nil + return CreatePaginatedResponse(comments, pagination.Page) } func GetSubIssues(ctx context.Context, client *github.Client, owner string, repo string, issueNumber int, pagination PaginationParams) (*mcp.CallToolResult, error) { opts := &github.IssueListOptions{ ListOptions: github.ListOptions{ Page: pagination.Page, - PerPage: pagination.PerPage, + PerPage: CursorFetchSize, // Fetch one extra to detect if more data exists }, } @@ -399,12 +394,7 @@ func GetSubIssues(ctx context.Context, client *github.Client, owner string, repo return mcp.NewToolResultError(fmt.Sprintf("failed to list sub-issues: %s", string(body))), nil } - r, err := json.Marshal(subIssues) - if err != nil { - return nil, fmt.Errorf("failed to marshal response: %w", err) - } - - return mcp.NewToolResultText(string(r)), nil + return CreatePaginatedResponse(subIssues, pagination.Page) } func GetIssueLabels(ctx context.Context, client *githubv4.Client, owner string, repo string, issueNumber int) (*mcp.CallToolResult, error) { diff --git a/pkg/github/issues_test.go b/pkg/github/issues_test.go index 03c57ce75..8543e46d8 100644 --- a/pkg/github/issues_test.go +++ b/pkg/github/issues_test.go @@ -254,8 +254,7 @@ func Test_SearchIssues(t *testing.T) { assert.Contains(t, tool.InputSchema.Properties, "repo") assert.Contains(t, tool.InputSchema.Properties, "sort") assert.Contains(t, tool.InputSchema.Properties, "order") - assert.Contains(t, tool.InputSchema.Properties, "perPage") - assert.Contains(t, tool.InputSchema.Properties, "page") + assert.Contains(t, tool.InputSchema.Properties, "cursor") assert.ElementsMatch(t, tool.InputSchema.Required, []string{"query"}) // Setup mock search results @@ -308,7 +307,7 @@ func Test_SearchIssues(t *testing.T) { "sort": "created", "order": "desc", "page": "1", - "per_page": "30", + "per_page": "11", }, ).andThen( mockResponse(t, http.StatusOK, mockSearchResult), @@ -337,7 +336,7 @@ func Test_SearchIssues(t *testing.T) { "sort": "created", "order": "asc", "page": "1", - "per_page": "30", + "per_page": "11", }, ).andThen( mockResponse(t, http.StatusOK, mockSearchResult), @@ -364,7 +363,7 @@ func Test_SearchIssues(t *testing.T) { map[string]string{ "q": "is:issue bug", "page": "1", - "per_page": "30", + "per_page": "11", }, ).andThen( mockResponse(t, http.StatusOK, mockSearchResult), @@ -388,7 +387,7 @@ func Test_SearchIssues(t *testing.T) { map[string]string{ "q": "is:issue feature", "page": "1", - "per_page": "30", + "per_page": "11", }, ).andThen( mockResponse(t, http.StatusOK, mockSearchResult), @@ -426,7 +425,7 @@ func Test_SearchIssues(t *testing.T) { map[string]string{ "q": "repo:github/github-mcp-server is:issue is:open (label:critical OR label:urgent)", "page": "1", - "per_page": "30", + "per_page": "11", }, ).andThen( mockResponse(t, http.StatusOK, mockSearchResult), @@ -449,7 +448,7 @@ func Test_SearchIssues(t *testing.T) { map[string]string{ "q": "is:issue repo:github/github-mcp-server critical", "page": "1", - "per_page": "30", + "per_page": "11", }, ).andThen( mockResponse(t, http.StatusOK, mockSearchResult), @@ -474,7 +473,7 @@ func Test_SearchIssues(t *testing.T) { map[string]string{ "q": "is:issue repo:octocat/Hello-World bug", "page": "1", - "per_page": "30", + "per_page": "11", }, ).andThen( mockResponse(t, http.StatusOK, mockSearchResult), @@ -497,7 +496,7 @@ func Test_SearchIssues(t *testing.T) { map[string]string{ "q": "repo:github/github-mcp-server is:issue (label:critical OR label:urgent OR label:high-priority OR label:blocker)", "page": "1", - "per_page": "30", + "per_page": "11", }, ).andThen( mockResponse(t, http.StatusOK, mockSearchResult), @@ -553,14 +552,32 @@ func Test_SearchIssues(t *testing.T) { // Parse the result and get the text content if no error textContent := getTextResult(t, result) - // Unmarshal and verify the result - var returnedResult github.IssuesSearchResult + // Unmarshal paginated search result + var returnedResult map[string]interface{} err = json.Unmarshal([]byte(textContent.Text), &returnedResult) require.NoError(t, err) - assert.Equal(t, *tc.expectedResult.Total, *returnedResult.Total) - assert.Equal(t, *tc.expectedResult.IncompleteResults, *returnedResult.IncompleteResults) - assert.Len(t, returnedResult.Issues, len(tc.expectedResult.Issues)) - for i, issue := range returnedResult.Issues { + + // Check totalCount and incompleteResults + if totalCount, ok := returnedResult["totalCount"].(float64); ok { + assert.Equal(t, float64(*tc.expectedResult.Total), totalCount) + } + if incompleteResults, ok := returnedResult["incompleteResults"].(bool); ok { + assert.Equal(t, *tc.expectedResult.IncompleteResults, incompleteResults) + } + + // Extract items (Issues array) + items, ok := returnedResult["items"].([]interface{}) + require.True(t, ok) + assert.Len(t, items, len(tc.expectedResult.Issues)) + + // Convert items to Issues array for comparison + itemsBytes, err := json.Marshal(items) + require.NoError(t, err) + var issues []*github.Issue + err = json.Unmarshal(itemsBytes, &issues) + require.NoError(t, err) + + for i, issue := range issues { assert.Equal(t, *tc.expectedResult.Issues[i].Number, *issue.Number) assert.Equal(t, *tc.expectedResult.Issues[i].Title, *issue.Title) assert.Equal(t, *tc.expectedResult.Issues[i].State, *issue.State) @@ -749,7 +766,7 @@ func Test_ListIssues(t *testing.T) { assert.Contains(t, tool.InputSchema.Properties, "direction") assert.Contains(t, tool.InputSchema.Properties, "since") assert.Contains(t, tool.InputSchema.Properties, "after") - assert.Contains(t, tool.InputSchema.Properties, "perPage") + // ListIssues uses GraphQL pagination with "after", not REST "cursor" assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo"}) // Mock issues data @@ -1598,8 +1615,7 @@ func Test_GetIssueComments(t *testing.T) { assert.Contains(t, tool.InputSchema.Properties, "owner") assert.Contains(t, tool.InputSchema.Properties, "repo") assert.Contains(t, tool.InputSchema.Properties, "issue_number") - assert.Contains(t, tool.InputSchema.Properties, "page") - assert.Contains(t, tool.InputSchema.Properties, "perPage") + assert.Contains(t, tool.InputSchema.Properties, "cursor") assert.ElementsMatch(t, tool.InputSchema.Required, []string{"method", "owner", "repo", "issue_number"}) // Setup mock comments for success case @@ -1654,7 +1670,7 @@ func Test_GetIssueComments(t *testing.T) { mock.GetReposIssuesCommentsByOwnerByRepoByIssueNumber, expectQueryParams(t, map[string]string{ "page": "2", - "per_page": "10", + "per_page": "11", }).andThen( mockResponse(t, http.StatusOK, mockComments), ), @@ -1713,9 +1729,10 @@ func Test_GetIssueComments(t *testing.T) { require.NoError(t, err) textContent := getTextResult(t, result) - // Unmarshal and verify the result + // Extract items from paginated response and unmarshal + itemsBytes := extractItemsFromPaginatedResponse(t, textContent.Text) var returnedComments []*github.IssueComment - err = json.Unmarshal([]byte(textContent.Text), &returnedComments) + err = json.Unmarshal(itemsBytes, &returnedComments) require.NoError(t, err) assert.Equal(t, len(tc.expectedComments), len(returnedComments)) if len(returnedComments) > 0 { @@ -2507,8 +2524,7 @@ func Test_GetSubIssues(t *testing.T) { assert.Contains(t, tool.InputSchema.Properties, "owner") assert.Contains(t, tool.InputSchema.Properties, "repo") assert.Contains(t, tool.InputSchema.Properties, "issue_number") - assert.Contains(t, tool.InputSchema.Properties, "page") - assert.Contains(t, tool.InputSchema.Properties, "perPage") + assert.Contains(t, tool.InputSchema.Properties, "cursor") assert.ElementsMatch(t, tool.InputSchema.Required, []string{"method", "owner", "repo", "issue_number"}) // Setup mock sub-issues for success case @@ -2577,7 +2593,7 @@ func Test_GetSubIssues(t *testing.T) { mock.GetReposIssuesSubIssuesByOwnerByRepoByIssueNumber, expectQueryParams(t, map[string]string{ "page": "2", - "per_page": "10", + "per_page": "11", }).andThen( mockResponse(t, http.StatusOK, mockSubIssues), ), @@ -2723,8 +2739,9 @@ func Test_GetSubIssues(t *testing.T) { textContent := getTextResult(t, result) // Unmarshal and verify the result + itemsBytes := extractItemsFromPaginatedResponse(t, textContent.Text) var returnedSubIssues []*github.Issue - err = json.Unmarshal([]byte(textContent.Text), &returnedSubIssues) + err = json.Unmarshal(itemsBytes, &returnedSubIssues) require.NoError(t, err) assert.Len(t, returnedSubIssues, len(tc.expectedSubIssues)) diff --git a/pkg/github/notifications.go b/pkg/github/notifications.go index 4da04889c..dd36cecc9 100644 --- a/pkg/github/notifications.go +++ b/pkg/github/notifications.go @@ -89,7 +89,7 @@ func ListNotifications(getClient GetClientFn, t translations.TranslationHelperFu Participating: filter == FilterOnlyParticipating, ListOptions: github.ListOptions{ Page: paginationParams.Page, - PerPage: paginationParams.PerPage, + PerPage: CursorFetchSize, // Fetch one extra to detect if more data exists }, } @@ -135,13 +135,7 @@ func ListNotifications(getClient GetClientFn, t translations.TranslationHelperFu return mcp.NewToolResultError(fmt.Sprintf("failed to get notifications: %s", string(body))), nil } - // Marshal response to JSON - r, err := json.Marshal(notifications) - if err != nil { - return nil, fmt.Errorf("failed to marshal response: %w", err) - } - - return mcp.NewToolResultText(string(r)), nil + return CreatePaginatedResponse(notifications, paginationParams.Page) } } diff --git a/pkg/github/notifications_test.go b/pkg/github/notifications_test.go index 98b132594..a7e8e7a2a 100644 --- a/pkg/github/notifications_test.go +++ b/pkg/github/notifications_test.go @@ -27,8 +27,7 @@ func Test_ListNotifications(t *testing.T) { assert.Contains(t, tool.InputSchema.Properties, "before") assert.Contains(t, tool.InputSchema.Properties, "owner") assert.Contains(t, tool.InputSchema.Properties, "repo") - assert.Contains(t, tool.InputSchema.Properties, "page") - assert.Contains(t, tool.InputSchema.Properties, "perPage") + assert.Contains(t, tool.InputSchema.Properties, "cursor") // All fields are optional, so Required should be empty assert.Empty(t, tool.InputSchema.Required) @@ -140,8 +139,10 @@ func Test_ListNotifications(t *testing.T) { require.False(t, result.IsError) textContent := getTextResult(t, result) t.Logf("textContent: %s", textContent.Text) + // Extract items from paginated response and unmarshal + itemsBytes := extractItemsFromPaginatedResponse(t, textContent.Text) var returned []*github.Notification - err = json.Unmarshal([]byte(textContent.Text), &returned) + err = json.Unmarshal(itemsBytes, &returned) require.NoError(t, err) require.NotEmpty(t, returned) assert.Equal(t, *tc.expectedResult[0].ID, *returned[0].ID) diff --git a/pkg/github/pullrequests.go b/pkg/github/pullrequests.go index 4f5e1952c..77859f77c 100644 --- a/pkg/github/pullrequests.go +++ b/pkg/github/pullrequests.go @@ -220,7 +220,7 @@ func GetPullRequestStatus(ctx context.Context, client *github.Client, owner, rep func GetPullRequestFiles(ctx context.Context, client *github.Client, owner, repo string, pullNumber int, pagination PaginationParams) (*mcp.CallToolResult, error) { opts := &github.ListOptions{ - PerPage: pagination.PerPage, + PerPage: CursorFetchSize, // Fetch one extra to detect if more data exists Page: pagination.Page, } files, resp, err := client.PullRequests.ListFiles(ctx, owner, repo, pullNumber, opts) @@ -241,18 +241,13 @@ func GetPullRequestFiles(ctx context.Context, client *github.Client, owner, repo return mcp.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 mcp.NewToolResultText(string(r)), nil + return CreatePaginatedResponse(files, pagination.Page) } func GetPullRequestReviewComments(ctx context.Context, client *github.Client, owner, repo string, pullNumber int, pagination PaginationParams) (*mcp.CallToolResult, error) { opts := &github.PullRequestListCommentsOptions{ ListOptions: github.ListOptions{ - PerPage: pagination.PerPage, + PerPage: CursorFetchSize, // Fetch one extra to detect if more data exists Page: pagination.Page, }, } @@ -275,12 +270,7 @@ func GetPullRequestReviewComments(ctx context.Context, client *github.Client, ow return mcp.NewToolResultError(fmt.Sprintf("failed to get pull request review comments: %s", string(body))), nil } - r, err := json.Marshal(comments) - if err != nil { - return nil, fmt.Errorf("failed to marshal response: %w", err) - } - - return mcp.NewToolResultText(string(r)), nil + return CreatePaginatedResponse(comments, pagination.Page) } func GetPullRequestReviews(ctx context.Context, client *github.Client, owner, repo string, pullNumber int) (*mcp.CallToolResult, error) { @@ -788,7 +778,7 @@ func ListPullRequests(getClient GetClientFn, t translations.TranslationHelperFun Sort: sort, Direction: direction, ListOptions: github.ListOptions{ - PerPage: pagination.PerPage, + PerPage: CursorFetchSize, // Fetch one extra to detect if more data exists Page: pagination.Page, }, } @@ -828,12 +818,7 @@ func ListPullRequests(getClient GetClientFn, t translations.TranslationHelperFun } } - r, err := json.Marshal(prs) - if err != nil { - return nil, fmt.Errorf("failed to marshal response: %w", err) - } - - return mcp.NewToolResultText(string(r)), nil + return CreatePaginatedResponse(prs, pagination.Page) } } diff --git a/pkg/github/pullrequests_test.go b/pkg/github/pullrequests_test.go index a66e2852a..482eae6d8 100644 --- a/pkg/github/pullrequests_test.go +++ b/pkg/github/pullrequests_test.go @@ -590,8 +590,7 @@ func Test_ListPullRequests(t *testing.T) { assert.Contains(t, tool.InputSchema.Properties, "base") assert.Contains(t, tool.InputSchema.Properties, "sort") assert.Contains(t, tool.InputSchema.Properties, "direction") - assert.Contains(t, tool.InputSchema.Properties, "perPage") - assert.Contains(t, tool.InputSchema.Properties, "page") + assert.Contains(t, tool.InputSchema.Properties, "cursor") assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo"}) // Setup mock PRs for success case @@ -627,7 +626,7 @@ func Test_ListPullRequests(t *testing.T) { "state": "all", "sort": "created", "direction": "desc", - "per_page": "30", + "per_page": "11", "page": "1", }).andThen( mockResponse(t, http.StatusOK, mockPRs), @@ -695,8 +694,9 @@ func Test_ListPullRequests(t *testing.T) { textContent := getTextResult(t, result) // Unmarshal and verify the result + itemsBytes := extractItemsFromPaginatedResponse(t, textContent.Text) var returnedPRs []*github.PullRequest - err = json.Unmarshal([]byte(textContent.Text), &returnedPRs) + err = json.Unmarshal(itemsBytes, &returnedPRs) require.NoError(t, err) assert.Len(t, returnedPRs, 2) assert.Equal(t, *tc.expectedPRs[0].Number, *returnedPRs[0].Number) @@ -836,8 +836,7 @@ func Test_SearchPullRequests(t *testing.T) { assert.Contains(t, tool.InputSchema.Properties, "repo") assert.Contains(t, tool.InputSchema.Properties, "sort") assert.Contains(t, tool.InputSchema.Properties, "order") - assert.Contains(t, tool.InputSchema.Properties, "perPage") - assert.Contains(t, tool.InputSchema.Properties, "page") + assert.Contains(t, tool.InputSchema.Properties, "cursor") assert.ElementsMatch(t, tool.InputSchema.Required, []string{"query"}) mockSearchResult := &github.IssuesSearchResult{ @@ -889,7 +888,7 @@ func Test_SearchPullRequests(t *testing.T) { "sort": "created", "order": "desc", "page": "1", - "per_page": "30", + "per_page": "11", }, ).andThen( mockResponse(t, http.StatusOK, mockSearchResult), @@ -918,7 +917,7 @@ func Test_SearchPullRequests(t *testing.T) { "sort": "updated", "order": "asc", "page": "1", - "per_page": "30", + "per_page": "11", }, ).andThen( mockResponse(t, http.StatusOK, mockSearchResult), @@ -945,7 +944,7 @@ func Test_SearchPullRequests(t *testing.T) { map[string]string{ "q": "is:pr feature", "page": "1", - "per_page": "30", + "per_page": "11", }, ).andThen( mockResponse(t, http.StatusOK, mockSearchResult), @@ -969,7 +968,7 @@ func Test_SearchPullRequests(t *testing.T) { map[string]string{ "q": "is:pr review-required", "page": "1", - "per_page": "30", + "per_page": "11", }, ).andThen( mockResponse(t, http.StatusOK, mockSearchResult), @@ -1007,7 +1006,7 @@ func Test_SearchPullRequests(t *testing.T) { map[string]string{ "q": "is:pr repo:github/github-mcp-server is:open draft:false", "page": "1", - "per_page": "30", + "per_page": "11", }, ).andThen( mockResponse(t, http.StatusOK, mockSearchResult), @@ -1030,7 +1029,7 @@ func Test_SearchPullRequests(t *testing.T) { map[string]string{ "q": "is:pr repo:github/github-mcp-server author:octocat", "page": "1", - "per_page": "30", + "per_page": "11", }, ).andThen( mockResponse(t, http.StatusOK, mockSearchResult), @@ -1055,7 +1054,7 @@ func Test_SearchPullRequests(t *testing.T) { map[string]string{ "q": "is:pr repo:github/github-mcp-server (label:bug OR label:enhancement OR label:feature)", "page": "1", - "per_page": "30", + "per_page": "11", }, ).andThen( mockResponse(t, http.StatusOK, mockSearchResult), @@ -1112,13 +1111,32 @@ func Test_SearchPullRequests(t *testing.T) { textContent := getTextResult(t, result) // Unmarshal and verify the result - var returnedResult github.IssuesSearchResult + // Unmarshal paginated search result + var returnedResult map[string]interface{} err = json.Unmarshal([]byte(textContent.Text), &returnedResult) require.NoError(t, err) - assert.Equal(t, *tc.expectedResult.Total, *returnedResult.Total) - assert.Equal(t, *tc.expectedResult.IncompleteResults, *returnedResult.IncompleteResults) - assert.Len(t, returnedResult.Issues, len(tc.expectedResult.Issues)) - for i, issue := range returnedResult.Issues { + + // Check totalCount and incompleteResults + if totalCount, ok := returnedResult["totalCount"].(float64); ok { + assert.Equal(t, float64(*tc.expectedResult.Total), totalCount) + } + if incompleteResults, ok := returnedResult["incompleteResults"].(bool); ok { + assert.Equal(t, *tc.expectedResult.IncompleteResults, incompleteResults) + } + + // Extract items (Issues array) + items, ok := returnedResult["items"].([]interface{}) + require.True(t, ok) + assert.Len(t, items, len(tc.expectedResult.Issues)) + + // Convert items to Issues array for comparison + itemsBytes, err := json.Marshal(items) + require.NoError(t, err) + var issues []*github.Issue + err = json.Unmarshal(itemsBytes, &issues) + require.NoError(t, err) + + for i, issue := range issues { assert.Equal(t, *tc.expectedResult.Issues[i].Number, *issue.Number) assert.Equal(t, *tc.expectedResult.Issues[i].Title, *issue.Title) assert.Equal(t, *tc.expectedResult.Issues[i].State, *issue.State) @@ -1142,8 +1160,7 @@ func Test_GetPullRequestFiles(t *testing.T) { assert.Contains(t, tool.InputSchema.Properties, "owner") assert.Contains(t, tool.InputSchema.Properties, "repo") assert.Contains(t, tool.InputSchema.Properties, "pullNumber") - assert.Contains(t, tool.InputSchema.Properties, "page") - assert.Contains(t, tool.InputSchema.Properties, "perPage") + assert.Contains(t, tool.InputSchema.Properties, "cursor") assert.ElementsMatch(t, tool.InputSchema.Required, []string{"method", "owner", "repo", "pullNumber"}) // Setup mock PR files for success case @@ -1260,8 +1277,9 @@ func Test_GetPullRequestFiles(t *testing.T) { textContent := getTextResult(t, result) // Unmarshal and verify the result + itemsBytes := extractItemsFromPaginatedResponse(t, textContent.Text) var returnedFiles []*github.CommitFile - err = json.Unmarshal([]byte(textContent.Text), &returnedFiles) + err = json.Unmarshal(itemsBytes, &returnedFiles) require.NoError(t, err) assert.Len(t, returnedFiles, len(tc.expectedFiles)) for i, file := range returnedFiles { @@ -1682,8 +1700,9 @@ func Test_GetPullRequestComments(t *testing.T) { textContent := getTextResult(t, result) // Unmarshal and verify the result + itemsBytes := extractItemsFromPaginatedResponse(t, textContent.Text) var returnedComments []*github.PullRequestComment - err = json.Unmarshal([]byte(textContent.Text), &returnedComments) + err = json.Unmarshal(itemsBytes, &returnedComments) require.NoError(t, err) assert.Len(t, returnedComments, len(tc.expectedComments)) for i, comment := range returnedComments { diff --git a/pkg/github/repositories.go b/pkg/github/repositories.go index c188b0f68..71e554038 100644 --- a/pkg/github/repositories.go +++ b/pkg/github/repositories.go @@ -67,7 +67,7 @@ func GetCommit(getClient GetClientFn, t translations.TranslationHelperFunc) (too opts := &github.ListOptions{ Page: pagination.Page, - PerPage: pagination.PerPage, + PerPage: CursorFetchSize, // Fetch one extra to detect if more data exists } client, err := getClient(ctx) @@ -149,17 +149,12 @@ func ListCommits(getClient GetClientFn, t translations.TranslationHelperFunc) (t if err != nil { return mcp.NewToolResultError(err.Error()), nil } - // Set default perPage to 30 if not provided - perPage := pagination.PerPage - if perPage == 0 { - perPage = 30 - } opts := &github.CommitsListOptions{ SHA: sha, Author: author, ListOptions: github.ListOptions{ Page: pagination.Page, - PerPage: perPage, + PerPage: CursorFetchSize, // Fetch one extra to detect if more data exists }, } @@ -191,12 +186,7 @@ func ListCommits(getClient GetClientFn, t translations.TranslationHelperFunc) (t minimalCommits[i] = convertToMinimalCommit(commit, false) } - r, err := json.Marshal(minimalCommits) - if err != nil { - return nil, fmt.Errorf("failed to marshal response: %w", err) - } - - return mcp.NewToolResultText(string(r)), nil + return CreatePaginatedResponse(minimalCommits, pagination.Page) } } @@ -235,7 +225,7 @@ func ListBranches(getClient GetClientFn, t translations.TranslationHelperFunc) ( opts := &github.BranchListOptions{ ListOptions: github.ListOptions{ Page: pagination.Page, - PerPage: pagination.PerPage, + PerPage: CursorFetchSize, // Fetch one extra to detect if more data exists }, } @@ -268,12 +258,7 @@ func ListBranches(getClient GetClientFn, t translations.TranslationHelperFunc) ( minimalBranches = append(minimalBranches, convertToMinimalBranch(branch)) } - r, err := json.Marshal(minimalBranches) - if err != nil { - return nil, fmt.Errorf("failed to marshal response: %w", err) - } - - return mcp.NewToolResultText(string(r)), nil + return CreatePaginatedResponse(minimalBranches, pagination.Page) } } @@ -1256,7 +1241,7 @@ func ListTags(getClient GetClientFn, t translations.TranslationHelperFunc) (tool opts := &github.ListOptions{ Page: pagination.Page, - PerPage: pagination.PerPage, + PerPage: CursorFetchSize, // Fetch one extra to detect if more data exists } client, err := getClient(ctx) @@ -1282,12 +1267,7 @@ func ListTags(getClient GetClientFn, t translations.TranslationHelperFunc) (tool return mcp.NewToolResultError(fmt.Sprintf("failed to list tags: %s", string(body))), nil } - r, err := json.Marshal(tags) - if err != nil { - return nil, fmt.Errorf("failed to marshal response: %w", err) - } - - return mcp.NewToolResultText(string(r)), nil + return CreatePaginatedResponse(tags, pagination.Page) } } @@ -1412,7 +1392,7 @@ func ListReleases(getClient GetClientFn, t translations.TranslationHelperFunc) ( opts := &github.ListOptions{ Page: pagination.Page, - PerPage: pagination.PerPage, + PerPage: CursorFetchSize, // Fetch one extra to detect if more data exists } client, err := getClient(ctx) @@ -1434,12 +1414,7 @@ func ListReleases(getClient GetClientFn, t translations.TranslationHelperFunc) ( return mcp.NewToolResultError(fmt.Sprintf("failed to list releases: %s", string(body))), nil } - r, err := json.Marshal(releases) - if err != nil { - return nil, fmt.Errorf("failed to marshal response: %w", err) - } - - return mcp.NewToolResultText(string(r)), nil + return CreatePaginatedResponse(releases, pagination.Page) } } @@ -1741,7 +1716,7 @@ func ListStarredRepositories(getClient GetClientFn, t translations.TranslationHe opts := &github.ActivityListStarredOptions{ ListOptions: github.ListOptions{ Page: pagination.Page, - PerPage: pagination.PerPage, + PerPage: CursorFetchSize, // Fetch one extra to detect if more data exists }, } if sort != "" { @@ -1810,12 +1785,7 @@ func ListStarredRepositories(getClient GetClientFn, t translations.TranslationHe minimalRepos = append(minimalRepos, minimalRepo) } - r, err := json.Marshal(minimalRepos) - if err != nil { - return nil, fmt.Errorf("failed to marshal starred repositories: %w", err) - } - - return mcp.NewToolResultText(string(r)), nil + return CreatePaginatedResponse(minimalRepos, pagination.Page) } } diff --git a/pkg/github/repositories_test.go b/pkg/github/repositories_test.go index 8baca434e..2e4fe39ec 100644 --- a/pkg/github/repositories_test.go +++ b/pkg/github/repositories_test.go @@ -765,8 +765,7 @@ func Test_ListCommits(t *testing.T) { assert.Contains(t, tool.InputSchema.Properties, "repo") assert.Contains(t, tool.InputSchema.Properties, "sha") assert.Contains(t, tool.InputSchema.Properties, "author") - assert.Contains(t, tool.InputSchema.Properties, "page") - assert.Contains(t, tool.InputSchema.Properties, "perPage") + assert.Contains(t, tool.InputSchema.Properties, "cursor") assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo"}) // Setup mock commits for success case @@ -876,7 +875,7 @@ func Test_ListCommits(t *testing.T) { "author": "username", "sha": "main", "page": "1", - "per_page": "30", + "per_page": "11", }).andThen( mockResponse(t, http.StatusOK, mockCommits), ), @@ -898,7 +897,7 @@ func Test_ListCommits(t *testing.T) { mock.GetReposCommitsByOwnerByRepo, expectQueryParams(t, map[string]string{ "page": "2", - "per_page": "10", + "per_page": "11", }).andThen( mockResponse(t, http.StatusOK, mockCommits), ), @@ -960,9 +959,10 @@ func Test_ListCommits(t *testing.T) { // Parse the result and get the text content if no error textContent := getTextResult(t, result) - // Unmarshal and verify the result + // Extract items from paginated response and unmarshal + itemsBytes := extractItemsFromPaginatedResponse(t, textContent.Text) var returnedCommits []MinimalCommit - err = json.Unmarshal([]byte(textContent.Text), &returnedCommits) + err = json.Unmarshal(itemsBytes, &returnedCommits) require.NoError(t, err) assert.Len(t, returnedCommits, len(tc.expectedCommits)) for i, commit := range returnedCommits { @@ -1675,8 +1675,7 @@ func Test_ListBranches(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, "page") - assert.Contains(t, tool.InputSchema.Properties, "perPage") + assert.Contains(t, tool.InputSchema.Properties, "cursor") assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo"}) // Setup mock branches for success case @@ -1766,8 +1765,9 @@ func Test_ListBranches(t *testing.T) { require.NotEmpty(t, textContent.Text) // Verify response + itemsBytes := extractItemsFromPaginatedResponse(t, textContent.Text) var branches []*github.Branch - err = json.Unmarshal([]byte(textContent.Text), &branches) + err = json.Unmarshal(itemsBytes, &branches) require.NoError(t, err) assert.Len(t, branches, 2) assert.Equal(t, "main", *branches[0].Name) @@ -2064,8 +2064,9 @@ func Test_ListTags(t *testing.T) { textContent := getTextResult(t, result) // Parse and verify the result + itemsBytes := extractItemsFromPaginatedResponse(t, textContent.Text) var returnedTags []*github.RepositoryTag - err = json.Unmarshal([]byte(textContent.Text), &returnedTags) + err = json.Unmarshal(itemsBytes, &returnedTags) require.NoError(t, err) // Verify each tag @@ -2312,8 +2313,9 @@ func Test_ListReleases(t *testing.T) { require.NoError(t, err) textContent := getTextResult(t, result) + itemsBytes := extractItemsFromPaginatedResponse(t, textContent.Text) var returnedReleases []*github.RepositoryRelease - err = json.Unmarshal([]byte(textContent.Text), &returnedReleases) + err = json.Unmarshal(itemsBytes, &returnedReleases) require.NoError(t, err) assert.Len(t, returnedReleases, len(tc.expectedResult)) for i, rel := range returnedReleases { @@ -2923,8 +2925,7 @@ func Test_ListStarredRepositories(t *testing.T) { assert.Contains(t, tool.InputSchema.Properties, "username") assert.Contains(t, tool.InputSchema.Properties, "sort") assert.Contains(t, tool.InputSchema.Properties, "direction") - assert.Contains(t, tool.InputSchema.Properties, "page") - assert.Contains(t, tool.InputSchema.Properties, "perPage") + assert.Contains(t, tool.InputSchema.Properties, "cursor") assert.Empty(t, tool.InputSchema.Required) // All parameters are optional // Setup mock starred repositories @@ -3053,9 +3054,10 @@ func Test_ListStarredRepositories(t *testing.T) { // Parse the result and get the text content textContent := getTextResult(t, result) - // Unmarshal and verify the result + // Extract items from paginated response and unmarshal + itemsBytes := extractItemsFromPaginatedResponse(t, textContent.Text) var returnedRepos []MinimalRepository - err = json.Unmarshal([]byte(textContent.Text), &returnedRepos) + err = json.Unmarshal(itemsBytes, &returnedRepos) require.NoError(t, err) assert.Len(t, returnedRepos, tc.expectedCount) diff --git a/pkg/github/search.go b/pkg/github/search.go index 4f396b6b0..4268d211b 100644 --- a/pkg/github/search.go +++ b/pkg/github/search.go @@ -66,7 +66,7 @@ func SearchRepositories(getClient GetClientFn, t translations.TranslationHelperF Order: order, ListOptions: github.ListOptions{ Page: pagination.Page, - PerPage: pagination.PerPage, + PerPage: CursorFetchSize, // Fetch one extra to detect if more data exists }, } @@ -93,10 +93,15 @@ func SearchRepositories(getClient GetClientFn, t translations.TranslationHelperF } // Return either minimal or full response based on parameter - var r []byte if minimalOutput { - minimalRepos := make([]MinimalRepository, 0, len(result.Repositories)) - for _, repo := range result.Repositories { + // Limit to CursorPageSize items + reposToProcess := result.Repositories + if len(reposToProcess) > CursorPageSize { + reposToProcess = reposToProcess[:CursorPageSize] + } + + minimalRepos := make([]MinimalRepository, 0, len(reposToProcess)) + for _, repo := range reposToProcess { minimalRepo := MinimalRepository{ ID: repo.GetID(), Name: repo.GetName(), @@ -126,24 +131,24 @@ func SearchRepositories(getClient GetClientFn, t translations.TranslationHelperF minimalRepos = append(minimalRepos, minimalRepo) } - minimalResult := &MinimalSearchRepositoriesResult{ - TotalCount: result.GetTotal(), - IncompleteResults: result.GetIncompleteResults(), - Items: minimalRepos, + hasMore := len(result.Repositories) > CursorPageSize + minimalResult := map[string]interface{}{ + "totalCount": result.GetTotal(), + "incompleteResults": result.GetIncompleteResults(), + "items": minimalRepos, + "moreData": hasMore, + } + if hasMore { + minimalResult["cursor"] = EncodeCursor(pagination.Page + 1) } - r, err = json.Marshal(minimalResult) + r, err := json.Marshal(minimalResult) if err != nil { return nil, fmt.Errorf("failed to marshal minimal response: %w", err) } - } else { - r, err = json.Marshal(result) - if err != nil { - return nil, fmt.Errorf("failed to marshal full response: %w", err) - } + return mcp.NewToolResultText(string(r)), nil } - - return mcp.NewToolResultText(string(r)), nil + return CreatePaginatedSearchResponse(result, pagination.Page) } } @@ -190,7 +195,7 @@ func SearchCode(getClient GetClientFn, t translations.TranslationHelperFunc) (to Sort: sort, Order: order, ListOptions: github.ListOptions{ - PerPage: pagination.PerPage, + PerPage: CursorFetchSize, // Fetch one extra to detect if more data exists Page: pagination.Page, }, } @@ -218,12 +223,7 @@ func SearchCode(getClient GetClientFn, t translations.TranslationHelperFunc) (to return mcp.NewToolResultError(fmt.Sprintf("failed to search code: %s", string(body))), nil } - 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 CreatePaginatedSearchResponse(result, pagination.Page) } } @@ -250,7 +250,7 @@ func userOrOrgHandler(accountType string, getClient GetClientFn) server.ToolHand Sort: sort, Order: order, ListOptions: github.ListOptions{ - PerPage: pagination.PerPage, + PerPage: CursorFetchSize, // Fetch one extra to detect if more data exists Page: pagination.Page, }, } @@ -282,9 +282,15 @@ func userOrOrgHandler(accountType string, getClient GetClientFn) server.ToolHand return mcp.NewToolResultError(fmt.Sprintf("failed to search %ss: %s", accountType, string(body))), nil } - minimalUsers := make([]MinimalUser, 0, len(result.Users)) + // Limit to CursorPageSize items + usersToProcess := result.Users + if len(usersToProcess) > CursorPageSize { + usersToProcess = usersToProcess[:CursorPageSize] + } + + minimalUsers := make([]MinimalUser, 0, len(usersToProcess)) - for _, user := range result.Users { + for _, user := range usersToProcess { if user.Login != nil { mu := MinimalUser{ Login: user.GetLogin(), @@ -295,16 +301,23 @@ func userOrOrgHandler(accountType string, getClient GetClientFn) server.ToolHand minimalUsers = append(minimalUsers, mu) } } - minimalResp := &MinimalSearchUsersResult{ - TotalCount: result.GetTotal(), - IncompleteResults: result.GetIncompleteResults(), - Items: minimalUsers, + hasMore := len(result.Users) > CursorPageSize + minimalResp := map[string]interface{}{ + "totalCount": result.GetTotal(), + "incompleteResults": result.GetIncompleteResults(), + "items": minimalUsers, + "moreData": hasMore, } if result.Total != nil { - minimalResp.TotalCount = *result.Total + minimalResp["totalCount"] = *result.Total } if result.IncompleteResults != nil { - minimalResp.IncompleteResults = *result.IncompleteResults + minimalResp["incompleteResults"] = *result.IncompleteResults + } + if hasMore { + // Get pagination from request context + pagination, _ := OptionalPaginationParams(request) + minimalResp["cursor"] = EncodeCursor(pagination.Page + 1) } r, err := json.Marshal(minimalResp) diff --git a/pkg/github/search_test.go b/pkg/github/search_test.go index c70682f74..697417f70 100644 --- a/pkg/github/search_test.go +++ b/pkg/github/search_test.go @@ -25,8 +25,7 @@ func Test_SearchRepositories(t *testing.T) { assert.Contains(t, tool.InputSchema.Properties, "query") assert.Contains(t, tool.InputSchema.Properties, "sort") assert.Contains(t, tool.InputSchema.Properties, "order") - assert.Contains(t, tool.InputSchema.Properties, "page") - assert.Contains(t, tool.InputSchema.Properties, "perPage") + assert.Contains(t, tool.InputSchema.Properties, "cursor") assert.ElementsMatch(t, tool.InputSchema.Required, []string{"query"}) // Setup mock search results @@ -71,7 +70,7 @@ func Test_SearchRepositories(t *testing.T) { "sort": "stars", "order": "desc", "page": "2", - "per_page": "10", + "per_page": "11", }).andThen( mockResponse(t, http.StatusOK, mockSearchResult), ), @@ -95,7 +94,7 @@ func Test_SearchRepositories(t *testing.T) { expectQueryParams(t, map[string]string{ "q": "golang test", "page": "1", - "per_page": "30", + "per_page": "11", }).andThen( mockResponse(t, http.StatusOK, mockSearchResult), ), @@ -153,18 +152,43 @@ func Test_SearchRepositories(t *testing.T) { // Parse the result and get the text content if no error textContent := getTextResult(t, result) - // Unmarshal and verify the result - var returnedResult MinimalSearchRepositoriesResult + // Unmarshal paginated search result (includes totalCount, items, moreData, cursor) + var returnedResult map[string]interface{} err = json.Unmarshal([]byte(textContent.Text), &returnedResult) require.NoError(t, err) - assert.Equal(t, *tc.expectedResult.Total, returnedResult.TotalCount) - assert.Equal(t, *tc.expectedResult.IncompleteResults, returnedResult.IncompleteResults) - assert.Len(t, returnedResult.Items, len(tc.expectedResult.Repositories)) - for i, repo := range returnedResult.Items { - assert.Equal(t, *tc.expectedResult.Repositories[i].ID, repo.ID) - assert.Equal(t, *tc.expectedResult.Repositories[i].Name, repo.Name) - assert.Equal(t, *tc.expectedResult.Repositories[i].FullName, repo.FullName) - assert.Equal(t, *tc.expectedResult.Repositories[i].HTMLURL, repo.HTMLURL) + + // Check totalCount + if totalCount, ok := returnedResult["totalCount"].(float64); ok { + assert.Equal(t, float64(*tc.expectedResult.Total), totalCount) + } + if incompleteResults, ok := returnedResult["incompleteResults"].(bool); ok { + assert.Equal(t, *tc.expectedResult.IncompleteResults, incompleteResults) + } + + // Extract items from paginated response + items, ok := returnedResult["items"].([]interface{}) + require.True(t, ok, "items should be present in response") + assert.Len(t, items, len(tc.expectedResult.Repositories)) + + // Convert items to MinimalRepository for comparison + for i, item := range items { + if i >= len(tc.expectedResult.Repositories) { + break + } + itemMap, ok := item.(map[string]interface{}) + require.True(t, ok) + if id, ok := itemMap["id"].(float64); ok { + assert.Equal(t, float64(*tc.expectedResult.Repositories[i].ID), id) + } + if name, ok := itemMap["name"].(string); ok { + assert.Equal(t, *tc.expectedResult.Repositories[i].Name, name) + } + if fullName, ok := itemMap["fullName"].(string); ok { + assert.Equal(t, *tc.expectedResult.Repositories[i].FullName, fullName) + } + if htmlURL, ok := itemMap["htmlUrl"].(string); ok { + assert.Equal(t, *tc.expectedResult.Repositories[i].HTMLURL, htmlURL) + } } }) @@ -193,7 +217,7 @@ func Test_SearchRepositories_FullOutput(t *testing.T) { expectQueryParams(t, map[string]string{ "q": "golang test", "page": "1", - "per_page": "30", + "per_page": "11", }).andThen( mockResponse(t, http.StatusOK, mockSearchResult), ), @@ -215,17 +239,34 @@ func Test_SearchRepositories_FullOutput(t *testing.T) { textContent := getTextResult(t, result) - // Unmarshal as full GitHub API response - var returnedResult github.RepositoriesSearchResult + // Unmarshal paginated search result (full output still includes pagination metadata) + var returnedResult map[string]interface{} err = json.Unmarshal([]byte(textContent.Text), &returnedResult) require.NoError(t, err) - + // Verify it's the full API response, not minimal - assert.Equal(t, *mockSearchResult.Total, *returnedResult.Total) - assert.Equal(t, *mockSearchResult.IncompleteResults, *returnedResult.IncompleteResults) - assert.Len(t, returnedResult.Repositories, 1) - assert.Equal(t, *mockSearchResult.Repositories[0].ID, *returnedResult.Repositories[0].ID) - assert.Equal(t, *mockSearchResult.Repositories[0].Name, *returnedResult.Repositories[0].Name) + // Note: returnedResult is now a map with pagination metadata + if totalCount, ok := returnedResult["totalCount"].(float64); ok { + assert.Equal(t, float64(*mockSearchResult.Total), totalCount) + } + if incompleteResults, ok := returnedResult["incompleteResults"].(bool); ok { + assert.Equal(t, *mockSearchResult.IncompleteResults, incompleteResults) + } + + // Extract repositories from items array + items, ok := returnedResult["items"].([]interface{}) + require.True(t, ok, "items should be present in response") + assert.Len(t, items, 1) + + // Convert first item to map and verify + repoMap, ok := items[0].(map[string]interface{}) + require.True(t, ok) + if id, ok := repoMap["id"].(float64); ok { + assert.Equal(t, float64(*mockSearchResult.Repositories[0].ID), id) + } + if name, ok := repoMap["name"].(string); ok { + assert.Equal(t, *mockSearchResult.Repositories[0].Name, name) + } } func Test_SearchCode(t *testing.T) { @@ -239,8 +280,7 @@ func Test_SearchCode(t *testing.T) { assert.Contains(t, tool.InputSchema.Properties, "query") assert.Contains(t, tool.InputSchema.Properties, "sort") assert.Contains(t, tool.InputSchema.Properties, "order") - assert.Contains(t, tool.InputSchema.Properties, "perPage") - assert.Contains(t, tool.InputSchema.Properties, "page") + assert.Contains(t, tool.InputSchema.Properties, "cursor") assert.ElementsMatch(t, tool.InputSchema.Required, []string{"query"}) // Setup mock search results @@ -283,7 +323,7 @@ func Test_SearchCode(t *testing.T) { "sort": "indexed", "order": "desc", "page": "1", - "per_page": "30", + "per_page": "11", }).andThen( mockResponse(t, http.StatusOK, mockSearchResult), ), @@ -307,7 +347,7 @@ func Test_SearchCode(t *testing.T) { expectQueryParams(t, map[string]string{ "q": "fmt.Println language:go", "page": "1", - "per_page": "30", + "per_page": "11", }).andThen( mockResponse(t, http.StatusOK, mockSearchResult), ), @@ -365,14 +405,32 @@ func Test_SearchCode(t *testing.T) { // Parse the result and get the text content if no error textContent := getTextResult(t, result) - // Unmarshal and verify the result - var returnedResult github.CodeSearchResult + // Unmarshal paginated search result + var returnedResult map[string]interface{} err = json.Unmarshal([]byte(textContent.Text), &returnedResult) require.NoError(t, err) - assert.Equal(t, *tc.expectedResult.Total, *returnedResult.Total) - assert.Equal(t, *tc.expectedResult.IncompleteResults, *returnedResult.IncompleteResults) - assert.Len(t, returnedResult.CodeResults, len(tc.expectedResult.CodeResults)) - for i, code := range returnedResult.CodeResults { + + // Check totalCount and incompleteResults + if totalCount, ok := returnedResult["totalCount"].(float64); ok { + assert.Equal(t, float64(*tc.expectedResult.Total), totalCount) + } + if incompleteResults, ok := returnedResult["incompleteResults"].(bool); ok { + assert.Equal(t, *tc.expectedResult.IncompleteResults, incompleteResults) + } + + // Extract items (CodeResults array) + items, ok := returnedResult["items"].([]interface{}) + require.True(t, ok) + assert.Len(t, items, len(tc.expectedResult.CodeResults)) + + // Convert items array to CodeResult slice for comparison + itemsBytes, err := json.Marshal(items) + require.NoError(t, err) + var codeResults []*github.CodeResult + err = json.Unmarshal(itemsBytes, &codeResults) + require.NoError(t, err) + + for i, code := range codeResults { assert.Equal(t, *tc.expectedResult.CodeResults[i].Name, *code.Name) assert.Equal(t, *tc.expectedResult.CodeResults[i].Path, *code.Path) assert.Equal(t, *tc.expectedResult.CodeResults[i].SHA, *code.SHA) @@ -394,8 +452,7 @@ func Test_SearchUsers(t *testing.T) { assert.Contains(t, tool.InputSchema.Properties, "query") assert.Contains(t, tool.InputSchema.Properties, "sort") assert.Contains(t, tool.InputSchema.Properties, "order") - assert.Contains(t, tool.InputSchema.Properties, "perPage") - assert.Contains(t, tool.InputSchema.Properties, "page") + assert.Contains(t, tool.InputSchema.Properties, "cursor") assert.ElementsMatch(t, tool.InputSchema.Required, []string{"query"}) // Setup mock search results @@ -437,7 +494,7 @@ func Test_SearchUsers(t *testing.T) { "sort": "followers", "order": "desc", "page": "1", - "per_page": "30", + "per_page": "11", }).andThen( mockResponse(t, http.StatusOK, mockSearchResult), ), @@ -461,7 +518,7 @@ func Test_SearchUsers(t *testing.T) { expectQueryParams(t, map[string]string{ "q": "type:user location:finland language:go", "page": "1", - "per_page": "30", + "per_page": "11", }).andThen( mockResponse(t, http.StatusOK, mockSearchResult), ), @@ -481,7 +538,7 @@ func Test_SearchUsers(t *testing.T) { expectQueryParams(t, map[string]string{ "q": "type:user location:seattle followers:>100", "page": "1", - "per_page": "30", + "per_page": "11", }).andThen( mockResponse(t, http.StatusOK, mockSearchResult), ), @@ -501,7 +558,7 @@ func Test_SearchUsers(t *testing.T) { expectQueryParams(t, map[string]string{ "q": "type:user (location:seattle OR location:california) followers:>50", "page": "1", - "per_page": "30", + "per_page": "11", }).andThen( mockResponse(t, http.StatusOK, mockSearchResult), ), @@ -561,18 +618,43 @@ func Test_SearchUsers(t *testing.T) { textContent := getTextResult(t, result) - // Unmarshal and verify the result - var returnedResult MinimalSearchUsersResult + // Unmarshal paginated search result + var returnedResult map[string]interface{} err = json.Unmarshal([]byte(textContent.Text), &returnedResult) require.NoError(t, err) - assert.Equal(t, *tc.expectedResult.Total, returnedResult.TotalCount) - assert.Equal(t, *tc.expectedResult.IncompleteResults, returnedResult.IncompleteResults) - assert.Len(t, returnedResult.Items, len(tc.expectedResult.Users)) - for i, user := range returnedResult.Items { - assert.Equal(t, *tc.expectedResult.Users[i].Login, user.Login) - assert.Equal(t, *tc.expectedResult.Users[i].ID, user.ID) - assert.Equal(t, *tc.expectedResult.Users[i].HTMLURL, user.ProfileURL) - assert.Equal(t, *tc.expectedResult.Users[i].AvatarURL, user.AvatarURL) + + // Check totalCount and incompleteResults + if totalCount, ok := returnedResult["totalCount"].(float64); ok { + assert.Equal(t, float64(*tc.expectedResult.Total), totalCount) + } + if incompleteResults, ok := returnedResult["incompleteResults"].(bool); ok { + assert.Equal(t, *tc.expectedResult.IncompleteResults, incompleteResults) + } + + // Extract items + items, ok := returnedResult["items"].([]interface{}) + require.True(t, ok) + assert.Len(t, items, len(tc.expectedResult.Users)) + + // Convert items to MinimalUser for comparison + for i, item := range items { + if i >= len(tc.expectedResult.Users) { + break + } + itemMap, ok := item.(map[string]interface{}) + require.True(t, ok) + if login, ok := itemMap["login"].(string); ok { + assert.Equal(t, *tc.expectedResult.Users[i].Login, login) + } + if id, ok := itemMap["id"].(float64); ok { + assert.Equal(t, float64(*tc.expectedResult.Users[i].ID), id) + } + if profileURL, ok := itemMap["profileURL"].(string); ok { + assert.Equal(t, *tc.expectedResult.Users[i].HTMLURL, profileURL) + } + if avatarURL, ok := itemMap["avatarURL"].(string); ok { + assert.Equal(t, *tc.expectedResult.Users[i].AvatarURL, avatarURL) + } } }) } @@ -588,8 +670,7 @@ func Test_SearchOrgs(t *testing.T) { assert.Contains(t, tool.InputSchema.Properties, "query") assert.Contains(t, tool.InputSchema.Properties, "sort") assert.Contains(t, tool.InputSchema.Properties, "order") - assert.Contains(t, tool.InputSchema.Properties, "perPage") - assert.Contains(t, tool.InputSchema.Properties, "page") + assert.Contains(t, tool.InputSchema.Properties, "cursor") assert.ElementsMatch(t, tool.InputSchema.Required, []string{"query"}) // Setup mock search results @@ -628,7 +709,7 @@ func Test_SearchOrgs(t *testing.T) { expectQueryParams(t, map[string]string{ "q": "type:org github", "page": "1", - "per_page": "30", + "per_page": "11", }).andThen( mockResponse(t, http.StatusOK, mockSearchResult), ), @@ -648,7 +729,7 @@ func Test_SearchOrgs(t *testing.T) { expectQueryParams(t, map[string]string{ "q": "type:org location:california followers:>1000", "page": "1", - "per_page": "30", + "per_page": "11", }).andThen( mockResponse(t, http.StatusOK, mockSearchResult), ), @@ -668,7 +749,7 @@ func Test_SearchOrgs(t *testing.T) { expectQueryParams(t, map[string]string{ "q": "type:org (location:seattle OR location:california OR location:newyork) repos:>10", "page": "1", - "per_page": "30", + "per_page": "11", }).andThen( mockResponse(t, http.StatusOK, mockSearchResult), ), @@ -725,18 +806,43 @@ func Test_SearchOrgs(t *testing.T) { textContent := getTextResult(t, result) - // Unmarshal and verify the result - var returnedResult MinimalSearchUsersResult + // Unmarshal paginated search result + var returnedResult map[string]interface{} err = json.Unmarshal([]byte(textContent.Text), &returnedResult) require.NoError(t, err) - assert.Equal(t, *tc.expectedResult.Total, returnedResult.TotalCount) - assert.Equal(t, *tc.expectedResult.IncompleteResults, returnedResult.IncompleteResults) - assert.Len(t, returnedResult.Items, len(tc.expectedResult.Users)) - for i, org := range returnedResult.Items { - assert.Equal(t, *tc.expectedResult.Users[i].Login, org.Login) - assert.Equal(t, *tc.expectedResult.Users[i].ID, org.ID) - assert.Equal(t, *tc.expectedResult.Users[i].HTMLURL, org.ProfileURL) - assert.Equal(t, *tc.expectedResult.Users[i].AvatarURL, org.AvatarURL) + + // Check totalCount and incompleteResults + if totalCount, ok := returnedResult["totalCount"].(float64); ok { + assert.Equal(t, float64(*tc.expectedResult.Total), totalCount) + } + if incompleteResults, ok := returnedResult["incompleteResults"].(bool); ok { + assert.Equal(t, *tc.expectedResult.IncompleteResults, incompleteResults) + } + + // Extract items + items, ok := returnedResult["items"].([]interface{}) + require.True(t, ok) + assert.Len(t, items, len(tc.expectedResult.Users)) + + // Convert items to MinimalUser for comparison + for i, item := range items { + if i >= len(tc.expectedResult.Users) { + break + } + itemMap, ok := item.(map[string]interface{}) + require.True(t, ok) + if login, ok := itemMap["login"].(string); ok { + assert.Equal(t, *tc.expectedResult.Users[i].Login, login) + } + if id, ok := itemMap["id"].(float64); ok { + assert.Equal(t, float64(*tc.expectedResult.Users[i].ID), id) + } + if profileURL, ok := itemMap["profileURL"].(string); ok { + assert.Equal(t, *tc.expectedResult.Users[i].HTMLURL, profileURL) + } + if avatarURL, ok := itemMap["avatarURL"].(string); ok { + assert.Equal(t, *tc.expectedResult.Users[i].AvatarURL, avatarURL) + } } }) } diff --git a/pkg/github/search_utils.go b/pkg/github/search_utils.go index 00c5ae34b..1a1e0b1a0 100644 --- a/pkg/github/search_utils.go +++ b/pkg/github/search_utils.go @@ -2,7 +2,6 @@ package github import ( "context" - "encoding/json" "fmt" "io" "net/http" @@ -84,7 +83,7 @@ func searchHandler( Order: order, ListOptions: github.ListOptions{ Page: pagination.Page, - PerPage: pagination.PerPage, + PerPage: CursorFetchSize, // Fetch one extra to detect if more data exists }, } @@ -106,10 +105,5 @@ func searchHandler( return mcp.NewToolResultError(fmt.Sprintf("%s: %s", errorPrefix, string(body))), nil } - r, err := json.Marshal(result) - if err != nil { - return nil, fmt.Errorf("%s: failed to marshal response: %w", errorPrefix, err) - } - - return mcp.NewToolResultText(string(r)), nil + return CreatePaginatedSearchResponse(result, pagination.Page) } diff --git a/pkg/github/server.go b/pkg/github/server.go index b46425d80..7717c2918 100644 --- a/pkg/github/server.go +++ b/pkg/github/server.go @@ -4,6 +4,8 @@ import ( "encoding/json" "errors" "fmt" + "strconv" + "strings" "github.com/google/go-github/v76/github" "github.com/mark3labs/mcp-go/mcp" @@ -189,23 +191,23 @@ func OptionalStringArrayParam(r mcp.CallToolRequest, p string) ([]string, error) } } -// WithPagination adds REST API pagination parameters to a tool. -// https://docs.github.com/en/rest/using-the-rest-api/using-pagination-in-the-rest-api +// WithPagination adds cursor-based pagination parameter to a tool. +// Page size is fixed at 10 items. The cursor is an opaque string that should be +// passed back to retrieve the next page of results. func WithPagination() mcp.ToolOption { return func(tool *mcp.Tool) { - mcp.WithNumber("page", - mcp.Description("Page number for pagination (min 1)"), - mcp.Min(1), - )(tool) - - mcp.WithNumber("perPage", - mcp.Description("Results per page for pagination (min 1, max 100)"), - mcp.Min(1), - mcp.Max(100), + mcp.WithString("cursor", + mcp.Description("Cursor for pagination. Use the cursor value from the previous response's pagination metadata to retrieve the next page. Leave blank for the first page."), )(tool) } } +// CursorPageSize is the fixed page size for cursor-based pagination +const CursorPageSize = 10 + +// CursorFetchSize is the size to fetch from API (one extra to detect if more data exists) +const CursorFetchSize = CursorPageSize + 1 + // WithUnifiedPagination adds REST API pagination parameters to a tool. // GraphQL tools will use this and convert page/perPage to GraphQL cursor parameters internally. func WithUnifiedPagination() mcp.ToolOption { @@ -248,17 +250,60 @@ type PaginationParams struct { After string } -// OptionalPaginationParams returns the "page", "perPage", and "after" parameters from the request, -// or their default values if not present, "page" default is 1, "perPage" default is 30. -// In future, we may want to make the default values configurable, or even have this -// function returned from `withPagination`, where the defaults are provided alongside -// the min/max values. +// ParseCursor parses a cursor string into page and perPage values. +// The cursor format is "page=N" where N is the page number (1-indexed). +// Returns page 1 if cursor is empty or invalid. +func ParseCursor(cursor string) (page int, perPage int) { + perPage = CursorPageSize + page = 1 + + if cursor == "" { + return page, perPage + } + + // Parse cursor format: "page=N" + parts := strings.Split(cursor, "=") + if len(parts) == 2 && parts[0] == "page" { + if parsedPage, err := strconv.Atoi(parts[1]); err == nil && parsedPage > 0 { + page = parsedPage + } + } + + return page, perPage +} + +// EncodeCursor creates a cursor string from a page number. +func EncodeCursor(page int) string { + return fmt.Sprintf("page=%d", page) +} + +// OptionalPaginationParams returns pagination parameters from the request. +// This now uses cursor-based pagination where the cursor is parsed into page/perPage. +// For backward compatibility, it still supports page/perPage parameters if provided, +// but the new cursor-based approach is preferred. func OptionalPaginationParams(r mcp.CallToolRequest) (PaginationParams, error) { + // First check for cursor parameter (new approach) + cursor, err := OptionalParam[string](r, "cursor") + if err != nil { + return PaginationParams{}, err + } + + // If cursor is provided, parse it + if cursor != "" { + page, perPage := ParseCursor(cursor) + return PaginationParams{ + Page: page, + PerPage: perPage, + After: "", // Not used in REST API pagination + }, nil + } + + // Fallback to old page/perPage parameters for backward compatibility page, err := OptionalIntParamWithDefault(r, "page", 1) if err != nil { return PaginationParams{}, err } - perPage, err := OptionalIntParamWithDefault(r, "perPage", 30) + perPage, err := OptionalIntParamWithDefault(r, "perPage", CursorPageSize) if err != nil { return PaginationParams{}, err } @@ -333,6 +378,108 @@ func (p PaginationParams) ToGraphQLParams() (*GraphQLPaginationParams, error) { return cursor.ToGraphQLParams() } +// PaginatedResponse wraps paginated results with cursor metadata +type PaginatedResponse struct { + Items interface{} `json:"items"` + MoreData bool `json:"moreData"` + Cursor string `json:"cursor,omitempty"` +} + +// CreatePaginatedResponse creates a paginated response with cursor metadata. +// It takes the full results (which may have one extra item), the current page number, +// and returns a response with up to CursorPageSize items, plus metadata about whether +// more data exists and what the next cursor should be. +func CreatePaginatedResponse(items interface{}, currentPage int) (*mcp.CallToolResult, error) { + // Use reflection or type assertion to handle different slice types + // For now, we'll use a more generic approach with json marshaling + data, err := json.Marshal(items) + if err != nil { + return nil, fmt.Errorf("failed to marshal items: %w", err) + } + + // Parse the JSON to count items + var itemsArray []interface{} + if err := json.Unmarshal(data, &itemsArray); err != nil { + // If it's not an array, return as-is (no pagination metadata) + return mcp.NewToolResultText(string(data)), nil + } + + hasMore := len(itemsArray) > CursorPageSize + itemsToReturn := itemsArray + if hasMore { + itemsToReturn = itemsArray[:CursorPageSize] + } + + response := PaginatedResponse{ + Items: itemsToReturn, + MoreData: hasMore, + } + + if hasMore { + response.Cursor = EncodeCursor(currentPage + 1) + } + + resultData, err := json.Marshal(response) + if err != nil { + return nil, fmt.Errorf("failed to marshal paginated response: %w", err) + } + + return mcp.NewToolResultText(string(resultData)), nil +} + +// CreatePaginatedSearchResponse creates a paginated response for search results that have +// structured metadata (like TotalCount). It wraps the Items array with pagination metadata +// while preserving other fields. +func CreatePaginatedSearchResponse(searchResult interface{}, currentPage int) (*mcp.CallToolResult, error) { + data, err := json.Marshal(searchResult) + if err != nil { + return nil, fmt.Errorf("failed to marshal search result: %w", err) + } + + // Parse the search result to extract Items array + var resultMap map[string]interface{} + if err := json.Unmarshal(data, &resultMap); err != nil { + return mcp.NewToolResultText(string(data)), nil + } + + items, ok := resultMap["items"].([]interface{}) + if !ok { + // Try "Repositories", "Users", "CodeResults", "Issues", etc. + if repos, ok := resultMap["repositories"].([]interface{}); ok { + items = repos + } else if users, ok := resultMap["users"].([]interface{}); ok { + items = users + } else if codeResults, ok := resultMap["codeResults"].([]interface{}); ok { + items = codeResults + } else if issues, ok := resultMap["issues"].([]interface{}); ok { + items = issues + } else { + // If we can't find items, return as-is + return mcp.NewToolResultText(string(data)), nil + } + } + + hasMore := len(items) > CursorPageSize + itemsToReturn := items + if hasMore { + itemsToReturn = items[:CursorPageSize] + } + + // Update the result map with paginated items and add pagination metadata + resultMap["items"] = itemsToReturn + resultMap["moreData"] = hasMore + if hasMore { + resultMap["cursor"] = EncodeCursor(currentPage + 1) + } + + resultData, err := json.Marshal(resultMap) + if err != nil { + return nil, fmt.Errorf("failed to marshal paginated search response: %w", err) + } + + return mcp.NewToolResultText(string(resultData)), nil +} + func MarshalledTextResult(v any) *mcp.CallToolResult { data, err := json.Marshal(v) if err != nil { diff --git a/pkg/github/server_test.go b/pkg/github/server_test.go index b92664d75..a74149f94 100644 --- a/pkg/github/server_test.go +++ b/pkg/github/server_test.go @@ -490,34 +490,34 @@ func TestOptionalPaginationParams(t *testing.T) { params: map[string]any{}, expected: PaginationParams{ Page: 1, - PerPage: 30, + PerPage: CursorPageSize, }, expectError: false, }, { - name: "page parameter, default perPage", + name: "cursor parameter, default page", params: map[string]any{ - "page": float64(2), + "cursor": "page=2", }, expected: PaginationParams{ Page: 2, - PerPage: 30, + PerPage: CursorPageSize, }, expectError: false, }, { - name: "perPage parameter, default page", + name: "backward compatibility: page parameter, default perPage", params: map[string]any{ - "perPage": float64(50), + "page": float64(2), }, expected: PaginationParams{ - Page: 1, - PerPage: 50, + Page: 2, + PerPage: CursorPageSize, }, expectError: false, }, { - name: "page and perPage parameters", + name: "backward compatibility: page and perPage parameters", params: map[string]any{ "page": float64(2), "perPage": float64(50), @@ -529,20 +529,15 @@ func TestOptionalPaginationParams(t *testing.T) { expectError: false, }, { - name: "invalid page parameter", + name: "invalid cursor parameter", params: map[string]any{ - "page": "not-a-number", + "cursor": "invalid", }, - expected: PaginationParams{}, - expectError: true, - }, - { - name: "invalid perPage parameter", - params: map[string]any{ - "perPage": "not-a-number", + expected: PaginationParams{ + Page: 1, + PerPage: CursorPageSize, }, - expected: PaginationParams{}, - expectError: true, + expectError: false, // Invalid cursor defaults to page 1 }, }