diff --git a/.github/dependabot.yml b/.github/dependabot.yml
index 858141431..d43dcee74 100644
--- a/.github/dependabot.yml
+++ b/.github/dependabot.yml
@@ -13,3 +13,7 @@ updates:
directory: "/"
schedule:
interval: "weekly"
+ - package-ecosystem: "github-actions"
+ directory: "/"
+ schedule:
+ interval: "weekly"
diff --git a/.github/workflows/close-inactive-issues.yml b/.github/workflows/close-inactive-issues.yml
new file mode 100644
index 000000000..829233029
--- /dev/null
+++ b/.github/workflows/close-inactive-issues.yml
@@ -0,0 +1,28 @@
+name: Close inactive issues
+on:
+ schedule:
+ - cron: "30 8 * * *"
+
+jobs:
+ close-issues:
+ runs-on: ubuntu-latest
+ env:
+ PR_DAYS_BEFORE_STALE: 60
+ PR_DAYS_BEFORE_CLOSE: 120
+ PR_STALE_LABEL: stale
+ permissions:
+ issues: write
+ pull-requests: write
+ steps:
+ - uses: actions/stale@v9
+ with:
+ days-before-issue-stale: ${{ env.PR_DAYS_BEFORE_STALE }}
+ days-before-issue-close: ${{ env.PR_DAYS_BEFORE_CLOSE }}
+ stale-issue-label: ${{ env.PR_STALE_LABEL }}
+ stale-issue-message: "This issue is stale because it has been open for ${{ env.PR_DAYS_BEFORE_STALE }} days with no activity. Leave a comment to avoid closing this issue in ${{ env.PR_DAYS_BEFORE_CLOSE }} days."
+ close-issue-message: "This issue was closed because it has been inactive for ${{ env.PR_DAYS_BEFORE_CLOSE }} days since being marked as stale."
+ days-before-pr-stale: -1
+ days-before-pr-close: -1
+ # Start with the oldest items first
+ ascending: true
+ repo-token: ${{ secrets.GITHUB_TOKEN }}
diff --git a/.github/workflows/code-scanning.yml b/.github/workflows/code-scanning.yml
index 83d2c30be..5226e93f8 100644
--- a/.github/workflows/code-scanning.yml
+++ b/.github/workflows/code-scanning.yml
@@ -35,7 +35,7 @@ jobs:
runner: '["ubuntu-22.04"]'
steps:
- name: Checkout repository
- uses: actions/checkout@v4
+ uses: actions/checkout@v5
- name: Initialize CodeQL
uses: github/codeql-action/init@v3
diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml
index cd2d923cb..015a91e30 100644
--- a/.github/workflows/docker-publish.yml
+++ b/.github/workflows/docker-publish.yml
@@ -33,7 +33,7 @@ jobs:
steps:
- name: Checkout repository
- uses: actions/checkout@v4
+ uses: actions/checkout@v5
# Install the cosign tool except on PR
# https://github.com/sigstore/cosign-installer
@@ -63,7 +63,7 @@ jobs:
# https://github.com/docker/metadata-action
- name: Extract Docker metadata
id: meta
- uses: docker/metadata-action@96383f45573cb7f253c731d3b3ab81c87ef81934 # v5.0.0
+ uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
diff --git a/.github/workflows/docs-check.yml b/.github/workflows/docs-check.yml
index c28c528b2..fee936a67 100644
--- a/.github/workflows/docs-check.yml
+++ b/.github/workflows/docs-check.yml
@@ -14,7 +14,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout code
- uses: actions/checkout@v4
+ uses: actions/checkout@v5
- name: Set up Go
uses: actions/setup-go@v5
diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml
index e3ef25022..d845479b0 100644
--- a/.github/workflows/go.yml
+++ b/.github/workflows/go.yml
@@ -15,7 +15,7 @@ jobs:
steps:
- name: Check out code
- uses: actions/checkout@v4
+ uses: actions/checkout@v5
- name: Set up Go
uses: actions/setup-go@v5
diff --git a/.github/workflows/goreleaser.yml b/.github/workflows/goreleaser.yml
index 263607ee1..e5c4acbec 100644
--- a/.github/workflows/goreleaser.yml
+++ b/.github/workflows/goreleaser.yml
@@ -14,7 +14,7 @@ jobs:
steps:
- name: Check out code
- uses: actions/checkout@v4
+ uses: actions/checkout@v5
- name: Set up Go
uses: actions/setup-go@v5
diff --git a/.github/workflows/license-check.yml b/.github/workflows/license-check.yml
index 50f34ff60..5cc732c8f 100644
--- a/.github/workflows/license-check.yml
+++ b/.github/workflows/license-check.yml
@@ -11,7 +11,7 @@ jobs:
steps:
- name: Check out code
- uses: actions/checkout@v4
+ uses: actions/checkout@v5
- name: Set up Go
uses: actions/setup-go@v5
diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml
index b40193e72..f3000096b 100644
--- a/.github/workflows/lint.yml
+++ b/.github/workflows/lint.yml
@@ -13,7 +13,7 @@ jobs:
name: lint
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v5
- uses: actions/setup-go@v5
with:
go-version: stable
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 2307f6a28..4ad4ece12 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -16,6 +16,7 @@ We can't guarantee that every tool, feature, or pull request will be approved or
To increase the chances your request is accepted:
* Include real use cases or examples that demonstrate practical value
+* Please create an issue outlining the scenario and potential impact, so we can triage it promptly and prioritize accordingly.
* If your request stalls, you can open a Discussion post and link to your issue or PR
* We actively revisit requests that gain strong community engagement (πs, comments, or evidence of real-world use)
diff --git a/README.md b/README.md
index ae4d3627e..d1ce061da 100644
--- a/README.md
+++ b/README.md
@@ -36,7 +36,7 @@ Alternatively, to manually configure VS Code, choose the appropriate JSON block
-
+
```json
{
"servers": {
@@ -85,11 +85,6 @@ Alternatively, to manually configure VS Code, choose the appropriate JSON block
> **Note:** Each MCP host application needs to configure a GitHub App or OAuth App to support remote access via OAuth. Any host application that supports remote MCP servers should support the remote GitHub server with PAT authentication. Configuration details and support levels vary by host. Make sure to refer to the host application's documentation for more info.
-> β οΈ **Public Preview Status:** The **remote** GitHub MCP Server is currently in Public Preview. During preview, access may be gated depending on authentication type and surface:
-> - OAuth: Subject to GitHub Copilot Editor Preview Policy until GA
-> - PAT: Controlled via your organization's PAT policies
-> - MCP Servers in Copilot policy: Enables/disables access to all MCP servers in VS Code, with other Copilot editors migrating to this policy in the coming months.
-
### Configuration
See [Remote Server Documentation](/docs/remote-server.md) on how to pass additional configuration settings to the remote GitHub MCP Server.
@@ -130,7 +125,7 @@ To keep your GitHub PAT secure and reusable across different MCP hosts:
```bash
# CLI usage
claude mcp update github -e GITHUB_PERSONAL_ACCESS_TOKEN=$GITHUB_PAT
-
+
# In config files (where supported)
"env": {
"GITHUB_PERSONAL_ACCESS_TOKEN": "$GITHUB_PAT"
@@ -144,6 +139,7 @@ To keep your GitHub PAT secure and reusable across different MCP hosts:
- **Minimum scopes**: Only grant necessary permissions
- `repo` - Repository operations
- `read:packages` - Docker image access
+ - `read:org` - Organization team access
- **Separate tokens**: Use different PATs for different projects/environments
- **Regular rotation**: Update tokens periodically
- **Never commit**: Keep tokens out of version control
@@ -240,10 +236,11 @@ For other MCP host applications, please refer to our installation guides:
- **[GitHub Copilot in other IDEs](/docs/installation-guides/install-other-copilot-ides.md)** - Installation for JetBrains, Visual Studio, Eclipse, and Xcode with GitHub Copilot
- **[Claude Code & Claude Desktop](docs/installation-guides/install-claude.md)** - Installation guide for Claude Code and Claude Desktop
-- **[Cursor](docs/installation-guides/install-cursor.md)** - Installation guide for Cursor IDE
+- **[Cursor](docs/installation-guides/install-cursor.md)** - Installation guide for Cursor IDE
+- **[Google Gemini CLI](docs/installation-guides/install-gemini-cli.md)** - Installation guide for Google Gemini CLI
- **[Windsurf](docs/installation-guides/install-windsurf.md)** - Installation guide for Windsurf IDE
-For a complete overview of all installation options, see our **[Installation Guides Index](docs/installation-guides/installation-guides.md)**.
+For a complete overview of all installation options, see our **[Installation Guides Index](docs/installation-guides)**.
> **Note:** Any host application that supports local MCP servers should be able to access the local GitHub MCP server. However, the specific configuration process, syntax and stability of the integration will vary by host application. While many may follow a similar format to the examples above, this is not guaranteed. Please refer to your host application's documentation for the correct MCP configuration syntax and setup process.
@@ -287,12 +284,14 @@ The following sets of tools are available (all are on by default):
| `dependabot` | Dependabot tools |
| `discussions` | GitHub Discussions related tools |
| `experiments` | Experimental features that are not considered stable yet |
+| `gists` | GitHub Gist related tools |
| `issues` | GitHub Issues related tools |
| `notifications` | GitHub Notifications related tools |
| `orgs` | GitHub Organization related tools |
| `pull_requests` | GitHub Pull Request related tools |
| `repos` | GitHub Repository related tools |
| `secret_protection` | Secret protection related tools, such as GitHub Secret Scanning |
+| `security_advisories` | Security advisories related tools |
| `users` | GitHub User related tools |
@@ -420,6 +419,13 @@ The following sets of tools are available (all are on by default):
- **get_me** - Get my user profile
- No parameters required
+- **get_team_members** - Get team members
+ - `org`: Organization login (owner) that contains the team. (string, required)
+ - `team_slug`: Team slug (string, required)
+
+- **get_teams** - Get teams
+ - `user`: Username to get teams for. If not provided, uses the authenticated user. (string, optional)
+
@@ -449,22 +455,48 @@ The following sets of tools are available (all are on by default):
- `repo`: Repository name (string, required)
- **get_discussion_comments** - Get discussion comments
+ - `after`: Cursor for pagination. Use the endCursor from the previous page's PageInfo for GraphQL APIs. (string, optional)
- `discussionNumber`: Discussion Number (number, required)
- `owner`: Repository owner (string, required)
+ - `perPage`: Results per page for pagination (min 1, max 100) (number, optional)
- `repo`: Repository name (string, required)
- **list_discussion_categories** - List discussion categories
- - `after`: Cursor for pagination, use the 'after' field from the previous response (string, optional)
- - `before`: Cursor for pagination, use the 'before' field from the previous response (string, optional)
- - `first`: Number of categories to return per page (min 1, max 100) (number, optional)
- - `last`: Number of categories to return from the end (min 1, max 100) (number, optional)
- `owner`: Repository owner (string, required)
- - `repo`: Repository name (string, required)
+ - `repo`: Repository name. If not provided, discussion categories will be queried at the organisation level. (string, optional)
- **list_discussions** - List discussions
+ - `after`: Cursor for pagination. Use the endCursor from the previous page's PageInfo for GraphQL APIs. (string, optional)
- `category`: Optional filter by discussion category ID. If provided, only discussions with this category are listed. (string, optional)
+ - `direction`: Order direction. (string, optional)
+ - `orderBy`: Order discussions by field. If provided, the 'direction' also needs to be provided. (string, optional)
- `owner`: Repository owner (string, required)
- - `repo`: Repository name (string, required)
+ - `perPage`: Results per page for pagination (min 1, max 100) (number, optional)
+ - `repo`: Repository name. If not provided, discussions will be queried at the organisation level. (string, optional)
+
+
+
+
+
+Gists
+
+- **create_gist** - Create Gist
+ - `content`: Content for simple single-file gist creation (string, required)
+ - `description`: Description of the gist (string, optional)
+ - `filename`: Filename for simple single-file gist creation (string, required)
+ - `public`: Whether the gist is public (boolean, optional)
+
+- **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)
+ - `since`: Only gists updated after this time (ISO 8601 timestamp) (string, optional)
+ - `username`: GitHub username (omit for authenticated user's gists) (string, optional)
+
+- **update_gist** - Update Gist
+ - `content`: Content for the file (string, required)
+ - `description`: Updated description of the gist (string, optional)
+ - `filename`: Filename to update or create (string, required)
+ - `gist_id`: ID of the gist to update (string, required)
@@ -478,6 +510,13 @@ The following sets of tools are available (all are on by default):
- `owner`: Repository owner (string, required)
- `repo`: Repository name (string, required)
+- **add_sub_issue** - Add sub-issue
+ - `issue_number`: The number of the parent issue (number, required)
+ - `owner`: Repository owner (string, required)
+ - `replace_parent`: When true, replaces the sub-issue's current parent issue (boolean, optional)
+ - `repo`: Repository name (string, required)
+ - `sub_issue_id`: The ID of the sub-issue to add. ID is not the same as issue number (number, required)
+
- **assign_copilot_to_issue** - Assign Copilot to issue
- `issueNumber`: Issue number (number, required)
- `owner`: Repository owner (string, required)
@@ -491,6 +530,7 @@ The following sets of tools are available (all are on by default):
- `owner`: Repository owner (string, required)
- `repo`: Repository name (string, required)
- `title`: Issue title (string, required)
+ - `type`: Type of this issue (string, optional)
- **get_issue** - Get issue details
- `issue_number`: The number of the issue (number, required)
@@ -504,24 +544,48 @@ The following sets of tools are available (all are on by default):
- `perPage`: Results per page for pagination (min 1, max 100) (number, optional)
- `repo`: Repository name (string, required)
+- **list_issue_types** - List available issue types
+ - `owner`: The organization owner of the repository (string, required)
+
- **list_issues** - List issues
- - `direction`: Sort direction (string, optional)
+ - `after`: Cursor for pagination. Use the endCursor from the previous page's PageInfo for GraphQL APIs. (string, optional)
+ - `direction`: Order direction. If provided, the 'orderBy' also needs to be provided. (string, optional)
- `labels`: Filter by labels (string[], optional)
+ - `orderBy`: Order issues by field. If provided, the 'direction' also needs to be provided. (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)
- `since`: Filter by date (ISO 8601 timestamp) (string, optional)
- - `sort`: Sort order (string, optional)
- - `state`: Filter by state (string, optional)
+ - `state`: Filter by state, by default both open and closed issues are returned when not provided (string, optional)
+
+- **list_sub_issues** - List sub-issues
+ - `issue_number`: Issue number (number, required)
+ - `owner`: Repository owner (string, required)
+ - `page`: Page number for pagination (default: 1) (number, optional)
+ - `per_page`: Number of results per page (max 100, default: 30) (number, optional)
+ - `repo`: Repository name (string, required)
+
+- **remove_sub_issue** - Remove sub-issue
+ - `issue_number`: The number of the parent issue (number, required)
+ - `owner`: Repository owner (string, required)
+ - `repo`: Repository name (string, required)
+ - `sub_issue_id`: The ID of the sub-issue to remove. ID is not the same as issue number (number, required)
+
+- **reprioritize_sub_issue** - Reprioritize sub-issue
+ - `after_id`: The ID of the sub-issue to be prioritized after (either after_id OR before_id should be specified) (number, optional)
+ - `before_id`: The ID of the sub-issue to be prioritized before (either after_id OR before_id should be specified) (number, optional)
+ - `issue_number`: The number of the parent issue (number, required)
+ - `owner`: Repository owner (string, required)
+ - `repo`: Repository name (string, required)
+ - `sub_issue_id`: The ID of the sub-issue to reprioritize. ID is not the same as issue number (number, required)
- **search_issues** - Search issues
- `order`: Sort order (string, optional)
- - `owner`: Optional repository owner. If provided with repo, only notifications for this repository are listed. (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 notifications for this repository are listed. (string, optional)
+ - `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)
- **update_issue** - Edit issue
@@ -534,6 +598,7 @@ The following sets of tools are available (all are on by default):
- `repo`: Repository name (string, required)
- `state`: New state (string, optional)
- `title`: New title (string, optional)
+ - `type`: New issue type (string, optional)
@@ -581,7 +646,7 @@ The following sets of tools are available (all are on by default):
- `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`: Search query using GitHub organizations search syntax scoped to type:org (string, required)
+ - `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)
@@ -689,11 +754,11 @@ The following sets of tools are available (all are on by default):
- **search_pull_requests** - Search pull requests
- `order`: Sort order (string, optional)
- - `owner`: Optional repository owner. If provided with repo, only notifications for this repository are listed. (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 notifications for this repository are listed. (string, optional)
+ - `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)
- **submit_pending_pull_request_review** - Submit the requester's latest pending pull request review
@@ -706,10 +771,12 @@ The following sets of tools are available (all are on by default):
- **update_pull_request** - Edit pull request
- `base`: New base branch name (string, optional)
- `body`: New description (string, optional)
+ - `draft`: Mark pull request as draft (true) or ready for review (false) (boolean, optional)
- `maintainer_can_modify`: Allow maintainer edits (boolean, optional)
- `owner`: Repository owner (string, required)
- `pullNumber`: Pull request number to update (number, required)
- `repo`: Repository name (string, required)
+ - `reviewers`: GitHub usernames to request reviews from (string[], optional)
- `state`: New state (string, optional)
- `title`: New title (string, optional)
@@ -744,6 +811,7 @@ The following sets of tools are available (all are on by default):
- `autoInit`: Initialize with README (boolean, optional)
- `description`: Repository description (string, optional)
- `name`: Repository name (string, required)
+ - `organization`: Organization to create the repository in (omit to create in your personal account) (string, optional)
- `private`: Whether repo should be private (boolean, optional)
- **delete_file** - Delete file
@@ -759,6 +827,7 @@ The following sets of tools are available (all are on by default):
- `repo`: Repository name (string, required)
- **get_commit** - Get commit details
+ - `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)
@@ -772,6 +841,15 @@ The following sets of tools are available (all are on by default):
- `repo`: Repository name (string, required)
- `sha`: Accepts optional commit SHA. If specified, it will be used instead of ref (string, optional)
+- **get_latest_release** - Get latest release
+ - `owner`: Repository owner (string, required)
+ - `repo`: Repository name (string, required)
+
+- **get_release_by_tag** - Get a release by tag name
+ - `owner`: Repository owner (string, required)
+ - `repo`: Repository name (string, required)
+ - `tag`: Tag name (e.g., 'v1.0.0') (string, required)
+
- **get_tag** - Get tag details
- `owner`: Repository owner (string, required)
- `repo`: Repository name (string, required)
@@ -791,6 +869,12 @@ The following sets of tools are available (all are on by default):
- `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
+ - `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
- `owner`: Repository owner (string, required)
- `page`: Page number for pagination (min 1) (number, optional)
@@ -805,16 +889,17 @@ The following sets of tools are available (all are on by default):
- `repo`: Repository name (string, required)
- **search_code** - Search code
- - `order`: Sort order (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)
- - `q`: Search query using GitHub code search syntax (string, required)
+ - `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
+ - `minimal_output`: Return minimal repository information (default: true). When false, returns full GitHub API repository objects. (boolean, 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 (string, required)
+ - `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)
@@ -838,14 +923,49 @@ The following sets of tools are available (all are on by default):
+Security Advisories
+
+- **get_global_security_advisory** - Get a global security advisory
+ - `ghsaId`: GitHub Security Advisory ID (format: GHSA-xxxx-xxxx-xxxx). (string, required)
+
+- **list_global_security_advisories** - List global security advisories
+ - `affects`: Filter advisories by affected package or version (e.g. "package1,package2@1.0.0"). (string, optional)
+ - `cveId`: Filter by CVE ID. (string, optional)
+ - `cwes`: Filter by Common Weakness Enumeration IDs (e.g. ["79", "284", "22"]). (string[], optional)
+ - `ecosystem`: Filter by package ecosystem. (string, optional)
+ - `ghsaId`: Filter by GitHub Security Advisory ID (format: GHSA-xxxx-xxxx-xxxx). (string, optional)
+ - `isWithdrawn`: Whether to only return withdrawn advisories. (boolean, optional)
+ - `modified`: Filter by publish or update date or date range (ISO 8601 date or range). (string, optional)
+ - `published`: Filter by publish date or date range (ISO 8601 date or range). (string, optional)
+ - `severity`: Filter by severity. (string, optional)
+ - `type`: Advisory type. (string, optional)
+ - `updated`: Filter by update date or date range (ISO 8601 date or range). (string, optional)
+
+- **list_org_repository_security_advisories** - List org repository security advisories
+ - `direction`: Sort direction. (string, optional)
+ - `org`: The organization login. (string, required)
+ - `sort`: Sort field. (string, optional)
+ - `state`: Filter by advisory state. (string, optional)
+
+- **list_repository_security_advisories** - List repository security advisories
+ - `direction`: Sort direction. (string, optional)
+ - `owner`: The owner of the repository. (string, required)
+ - `repo`: The name of the repository. (string, required)
+ - `sort`: Sort field. (string, optional)
+ - `state`: Filter by advisory state. (string, optional)
+
+
+
+
+
Users
- **search_users** - Search users
- `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`: Search query using GitHub users search syntax scoped to type:user (string, required)
- - `sort`: Sort field by category (string, 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/cmd/github-mcp-server/generate_docs.go b/cmd/github-mcp-server/generate_docs.go
index 983ed4398..89cc37c22 100644
--- a/cmd/github-mcp-server/generate_docs.go
+++ b/cmd/github-mcp-server/generate_docs.go
@@ -13,7 +13,7 @@ import (
"github.com/github/github-mcp-server/pkg/raw"
"github.com/github/github-mcp-server/pkg/toolsets"
"github.com/github/github-mcp-server/pkg/translations"
- gogithub "github.com/google/go-github/v73/github"
+ gogithub "github.com/google/go-github/v74/github"
"github.com/mark3labs/mcp-go/mcp"
"github.com/shurcooL/githubv4"
"github.com/spf13/cobra"
@@ -64,7 +64,7 @@ func generateReadmeDocs(readmePath string) error {
t, _ := translations.TranslationHelper()
// Create toolset group with mock clients
- tsg := github.DefaultToolsetGroup(false, mockGetClient, mockGetGQLClient, mockGetRawClient, t)
+ tsg := github.DefaultToolsetGroup(false, mockGetClient, mockGetGQLClient, mockGetRawClient, t, 5000)
// Generate toolsets documentation
toolsetsDoc := generateToolsetsDoc(tsg)
@@ -302,7 +302,7 @@ func generateRemoteToolsetsDoc() string {
t, _ := translations.TranslationHelper()
// Create toolset group with mock clients
- tsg := github.DefaultToolsetGroup(false, mockGetClient, mockGetGQLClient, mockGetRawClient, t)
+ tsg := github.DefaultToolsetGroup(false, mockGetClient, mockGetGQLClient, mockGetRawClient, t, 5000)
// Generate table header
buf.WriteString("| Name | Description | API URL | 1-Click Install (VS Code) | Read-only Link | 1-Click Read-only Install (VS Code) |\n")
diff --git a/cmd/github-mcp-server/main.go b/cmd/github-mcp-server/main.go
index cad002666..0a4545835 100644
--- a/cmd/github-mcp-server/main.go
+++ b/cmd/github-mcp-server/main.go
@@ -55,6 +55,7 @@ var (
ExportTranslations: viper.GetBool("export-translations"),
EnableCommandLogging: viper.GetBool("enable-command-logging"),
LogFilePath: viper.GetString("log-file"),
+ ContentWindowSize: viper.GetInt("content-window-size"),
}
return ghmcp.RunStdioServer(stdioServerConfig)
},
@@ -75,6 +76,7 @@ func init() {
rootCmd.PersistentFlags().Bool("enable-command-logging", false, "When enabled, the server will log all command requests and responses to the log file")
rootCmd.PersistentFlags().Bool("export-translations", false, "Save translations to a JSON file")
rootCmd.PersistentFlags().String("gh-host", "", "Specify the GitHub hostname (for GitHub Enterprise etc.)")
+ rootCmd.PersistentFlags().Int("content-window-size", 5000, "Specify the content window size")
// Bind flag to viper
_ = viper.BindPFlag("toolsets", rootCmd.PersistentFlags().Lookup("toolsets"))
@@ -84,6 +86,7 @@ func init() {
_ = viper.BindPFlag("enable-command-logging", rootCmd.PersistentFlags().Lookup("enable-command-logging"))
_ = viper.BindPFlag("export-translations", rootCmd.PersistentFlags().Lookup("export-translations"))
_ = viper.BindPFlag("host", rootCmd.PersistentFlags().Lookup("gh-host"))
+ _ = viper.BindPFlag("content-window-size", rootCmd.PersistentFlags().Lookup("content-window-size"))
// Add subcommands
rootCmd.AddCommand(stdioCmd)
diff --git a/cmd/mcpcurl/README.md b/cmd/mcpcurl/README.md
index 317c2b8e5..717ea207f 100644
--- a/cmd/mcpcurl/README.md
+++ b/cmd/mcpcurl/README.md
@@ -15,6 +15,26 @@ be executed against the configured MCP server.
## Installation
+### Prerequisites
+- Go 1.21 or later
+- Access to the GitHub MCP Server from either Docker or local build
+
+### Build from Source
+```bash
+cd cmd/mcpcurl
+go build -o mcpcurl
+```
+
+### Using Go Install
+```bash
+go install github.com/github/github-mcp-server/cmd/mcpcurl@latest
+```
+
+### Verify Installation
+```bash
+./mcpcurl --help
+```
+
## Usage
```console
diff --git a/cmd/mcpcurl/main.go b/cmd/mcpcurl/main.go
index bc192587a..17b4bc77c 100644
--- a/cmd/mcpcurl/main.go
+++ b/cmd/mcpcurl/main.go
@@ -280,6 +280,8 @@ func addCommandFromTool(toolsCmd *cobra.Command, tool *Tool, prettyPrint bool) {
}
case "number":
cmd.Flags().Float64(name, 0, description)
+ case "integer":
+ cmd.Flags().Int64(name, 0, description)
case "boolean":
cmd.Flags().Bool(name, false, description)
case "array":
@@ -319,6 +321,10 @@ func buildArgumentsMap(cmd *cobra.Command, tool *Tool) (map[string]interface{},
if value, _ := cmd.Flags().GetFloat64(name); value != 0 {
arguments[name] = value
}
+ case "integer":
+ if value, _ := cmd.Flags().GetInt64(name); value != 0 {
+ arguments[name] = value
+ }
case "boolean":
// For boolean, we need to check if it was explicitly set
if cmd.Flags().Changed(name) {
diff --git a/docs/installation-guides/README.md b/docs/installation-guides/README.md
index f55cc6bef..13af2f7aa 100644
--- a/docs/installation-guides/README.md
+++ b/docs/installation-guides/README.md
@@ -6,6 +6,7 @@ This directory contains detailed installation instructions for the GitHub MCP Se
- **[GitHub Copilot in other IDEs](install-other-copilot-ides.md)** - Installation for JetBrains, Visual Studio, Eclipse, and Xcode with GitHub Copilot
- **[Claude Applications](install-claude.md)** - Installation guide for Claude Web, Claude Desktop and Claude Code CLI
- **[Cursor](install-cursor.md)** - Installation guide for Cursor IDE
+- **[Google Gemini CLI](install-gemini-cli.md)** - Installation guide for Google Gemini CLI
- **[Windsurf](install-windsurf.md)** - Installation guide for Windsurf IDE
## Support by Host Application
@@ -19,6 +20,7 @@ This directory contains detailed installation instructions for the GitHub MCP Se
| Claude Code | β
| β
PAT + β No OAuth| GitHub MCP Server binary or remote URL, GitHub PAT | Easy |
| Claude Desktop | β
| β
PAT + β No OAuth | Docker or Go build, GitHub PAT | Moderate |
| Cursor | β
| β
PAT + β No OAuth | Docker or Go build, GitHub PAT | Easy |
+| Google Gemini CLI | β
| β
PAT + β No OAuth | Docker or Go build, GitHub PAT | Easy |
| Windsurf | β
| β
PAT + β No OAuth | Docker or Go build, GitHub PAT | Easy |
| Copilot in Xcode | β
| β
PAT + β No OAuth | Local: Docker or Go build, GitHub PAT Remote: Copilot for Xcode latest version | Easy |
| Copilot in Eclipse | β
| β
PAT + β No OAuth | Local: Docker or Go build, GitHub PAT Remote: TBD | Easy |
diff --git a/docs/installation-guides/install-claude.md b/docs/installation-guides/install-claude.md
index 2c50be2f9..1a5b789f4 100644
--- a/docs/installation-guides/install-claude.md
+++ b/docs/installation-guides/install-claude.md
@@ -1,124 +1,98 @@
# Install GitHub MCP Server in Claude Applications
-This guide covers installation of the GitHub MCP server for Claude Code CLI, Claude Desktop, and Claude Web applications.
-
-## Claude Web (claude.ai)
-
-Claude Web supports remote MCP servers through the Integrations built-in feature.
+## Claude Code CLI
### Prerequisites
+- Claude Code CLI installed
+- [GitHub Personal Access Token](https://github.com/settings/personal-access-tokens/new)
+- For local setup: [Docker](https://www.docker.com/) installed and running
+- Open Claude Code inside the directory for your project (recommended for best experience and clear scope of configuration)
-1. Claude Pro, Team, or Enterprise account (Integrations not available on free plan)
-2. [GitHub Personal Access Token](https://github.com/settings/personal-access-tokens/new)
-
-### Installation
-
-**Note**: As of July 2025, the remote GitHub MCP Server has known compatibility issues with Claude Web. While Claude Web supports remote MCP servers from other providers (like Atlassian, Zapier, Notion), the GitHub MCP Server integration may not work reliably.
-
-For other remote MCP servers that do work with Claude Web:
-
-1. Go to [claude.ai](https://claude.ai) and log in
-2. Click your profile icon β **Settings**
-3. Navigate to **Integrations** section
-4. Click **+ Add integration** or **Add More**
-5. Enter the remote server URL
-6. Follow the OAuth authentication flow when prompted
+
+Storing Your PAT Securely
+
-**Alternative**: Use Claude Desktop or Claude Code CLI for reliable GitHub MCP Server integration.
+For security, avoid hardcoding your token. One common approach:
----
-
-## Claude Code CLI
-
-Claude Code CLI provides command-line access to Claude with MCP server integration.
-
-### Prerequisites
+1. Store your token in `.env` file
+```
+GITHUB_PAT=your_token_here
+```
-1. Claude Code CLI installed
-2. [GitHub Personal Access Token](https://github.com/settings/personal-access-tokens/new)
-3. [Docker](https://www.docker.com/) installed and running
+2. Add to .gitignore
+```bash
+echo -e ".env\n.mcp.json" >> .gitignore
+```
-### Installation
+
-Run the following command to add the GitHub MCP server using Docker:
+### Remote Server Setup (Streamable HTTP)
+1. Run the following command in the Claude Code CLI
```bash
-claude mcp add github -- docker run -i --rm -e GITHUB_PERSONAL_ACCESS_TOKEN ghcr.io/github/github-mcp-server
+claude mcp add --transport http github https://api.githubcopilot.com/mcp -H "Authorization: Bearer YOUR_GITHUB_PAT"
```
-Then set the environment variable:
+With an environment variable:
```bash
-claude mcp update github -e GITHUB_PERSONAL_ACCESS_TOKEN=your_github_pat
+claude mcp add --transport http github https://api.githubcopilot.com/mcp -H "Authorization: Bearer $(grep GITHUB_PAT .env | cut -d '=' -f2)"
```
+2. Restart Claude Code
+3. Run `claude mcp list` to see if the GitHub server is configured
+
+### Local Server Setup (Docker required)
-Or as a single command with the token inline:
+### With Docker
+1. Run the following command in the Claude Code CLI:
```bash
-claude mcp add-json github '{"command": "docker", "args": ["run", "-i", "--rm", "-e", "GITHUB_PERSONAL_ACCESS_TOKEN", "ghcr.io/github/github-mcp-server"], "env": {"GITHUB_PERSONAL_ACCESS_TOKEN": "your_github_pat"}}'
+claude mcp add github -e GITHUB_PERSONAL_ACCESS_TOKEN=YOUR_GITHUB_PAT -- docker run -i --rm -e GITHUB_PERSONAL_ACCESS_TOKEN ghcr.io/github/github-mcp-server
```
-**Important**: The npm package `@modelcontextprotocol/server-github` is no longer supported as of April 2025. Use the official Docker image `ghcr.io/github/github-mcp-server` instead.
+With an environment variable:
+```bash
+claude mcp add github -e GITHUB_PERSONAL_ACCESS_TOKEN=$(grep GITHUB_PAT .env | cut -d '=' -f2) -- docker run -i --rm -e GITHUB_PERSONAL_ACCESS_TOKEN ghcr.io/github/github-mcp-server
+```
+2. Restart Claude Code
+3. Run `claude mcp list` to see if the GitHub server is configured
-### Configuration Options
+### With a Binary (no Docker)
-- Use `-s user` to add the server to your user configuration (available across all projects)
-- Use `-s project` to add the server to project-specific configuration (shared via `.mcp.json`)
-- Default scope is `local` (available only to you in the current project)
+1. Download [release binary](https://github.com/github/github-mcp-server/releases)
+2. Add to your `PATH`
+3. Run:
+```bash
+claude mcp add-json github '{"command": "github-mcp-server", "args": ["stdio"], "env": {"GITHUB_PERSONAL_ACCESS_TOKEN": "YOUR_GITHUB_PAT"}}'
+```
+2. Restart Claude Code
+3. Run `claude mcp list` to see if the GitHub server is configured
### Verification
-
-Run the following command to verify the installation:
```bash
claude mcp list
+claude mcp get github
```
---
## Claude Desktop
-Claude Desktop provides a graphical interface for interacting with the GitHub MCP Server.
+> β οΈ **Note**: Some users have reported compatibility issues with Claude Desktop and Docker-based MCP servers. We're investigating. If you experience issues, try using another MCP host, while we look into it!
### Prerequisites
+- Claude Desktop installed (latest version)
+- [GitHub Personal Access Token](https://github.com/settings/personal-access-tokens/new)
+- [Docker](https://www.docker.com/) installed and running
-1. Claude Desktop installed
-2. [GitHub Personal Access Token](https://github.com/settings/personal-access-tokens/new)
-3. [Docker](https://www.docker.com/) installed and running
+> **Note**: Claude Desktop supports MCP servers that are both local (stdio) and remote ("connectors"). Remote servers can generally be added via Settings β Connectors β "Add custom connector". However, the GitHub remote MCP server requires OAuth authentication through a registered GitHub App (or OAuth App), which is not currently supported. Use the local Docker setup instead.
### Configuration File Location
-
- **macOS**: `~/Library/Application Support/Claude/claude_desktop_config.json`
- **Windows**: `%APPDATA%\Claude\claude_desktop_config.json`
-- **Linux**: `~/.config/Claude/claude_desktop_config.json` (unofficial support)
-
-### Installation
-
-Add the following to your `claude_desktop_config.json`:
-
-```json
-{
- "mcpServers": {
- "github": {
- "command": "docker",
- "args": [
- "run",
- "-i",
- "--rm",
- "-e",
- "GITHUB_PERSONAL_ACCESS_TOKEN",
- "ghcr.io/github/github-mcp-server"
- ],
- "env": {
- "GITHUB_PERSONAL_ACCESS_TOKEN": "your_github_pat"
- }
- }
- }
-}
-```
+- **Linux**: `~/.config/Claude/claude_desktop_config.json`
-**Important**: The npm package `@modelcontextprotocol/server-github` is no longer supported as of April 2025. Use the official Docker image `ghcr.io/github/github-mcp-server` instead.
+### Local Server Setup (Docker)
-### Using Environment Variables
-
-Claude Desktop supports environment variable references. You can use:
+Add this codeblock to your `claude_desktop_config.json`:
```json
{
@@ -134,71 +108,60 @@ Claude Desktop supports environment variable references. You can use:
"ghcr.io/github/github-mcp-server"
],
"env": {
- "GITHUB_PERSONAL_ACCESS_TOKEN": "$GITHUB_PAT"
+ "GITHUB_PERSONAL_ACCESS_TOKEN": "YOUR_GITHUB_PAT"
}
}
}
}
```
-Then set the environment variable in your system before starting Claude Desktop.
-
-### Installation Steps
-
+### Manual Setup Steps
1. Open Claude Desktop
-2. Go to Settings (from the Claude menu) β Developer β Edit Config
-3. Add your chosen configuration
-4. Save the file
-5. Restart Claude Desktop
-
-### Verification
-
-After restarting, you should see:
-- An MCP icon in the Claude Desktop interface
-- The GitHub server listed as "running" in Developer settings
+2. Go to Settings β Developer β Edit Config
+3. Paste the code block above in your configuration file
+4. If you're navigating to the configuration file outside of the app:
+ - **macOS**: `~/Library/Application Support/Claude/claude_desktop_config.json`
+ - **Windows**: `%APPDATA%\Claude\claude_desktop_config.json`
+5. Open the file in a text editor
+6. Paste one of the code blocks above, based on your chosen configuration (remote or local)
+7. Replace `YOUR_GITHUB_PAT` with your actual token or $GITHUB_PAT environment variable
+8. Save the file
+9. Restart Claude Desktop
---
## Troubleshooting
-### Claude Web
-- Currently experiencing compatibility issues with the GitHub MCP Server
-- Try other remote MCP servers (Atlassian, Zapier, Notion) which work reliably
-- Use Claude Desktop or Claude Code CLI as alternatives for GitHub integration
-
-### Claude Code CLI
-- Verify the command syntax is correct (note the single quotes around the JSON)
-- Ensure Docker is running: `docker --version`
-- Use `/mcp` command within Claude Code to check server status
-
-### Claude Desktop
-- Check logs at:
- - **macOS**: `~/Library/Logs/Claude/`
- - **Windows**: `%APPDATA%\Claude\logs\`
-- Look for `mcp-server-github.log` for server-specific errors
-- Ensure configuration file is valid JSON
-- Try running the Docker command manually in terminal to diagnose issues
-
-### Common Issues
-- **Invalid JSON**: Validate your configuration at [jsonlint.com](https://jsonlint.com)
-- **PAT issues**: Ensure your GitHub PAT has required scopes
-- **Docker not found**: Install Docker Desktop and ensure it's running
-- **Docker image pull fails**: Try `docker logout ghcr.io` then retry
-
----
-
-## Security Best Practices
-
-- **Protect configuration files**: Set appropriate file permissions
-- **Use environment variables** when possible instead of hardcoding tokens
-- **Limit PAT scope** to only necessary permissions
-- **Regularly rotate** your GitHub Personal Access Tokens
-- **Never commit** configuration files containing tokens to version control
+**Authentication Failed:**
+- Verify PAT has `repo` scope
+- Check token hasn't expired
+
+**Remote Server:**
+- Verify URL: `https://api.githubcopilot.com/mcp`
+
+**Docker Issues (Local Only):**
+- Ensure Docker Desktop is running
+- Try: `docker pull ghcr.io/github/github-mcp-server`
+- If pull fails: `docker logout ghcr.io` then retry
+
+**Server Not Starting / Tools Not Showing:**
+- Run `claude mcp list` to view currently configured MCP servers
+- Validate JSON syntax
+- If using an environment variable to store your PAT, make sure you're properly sourcing your PAT using the environment variable
+- Restart Claude Code and check `/mcp` command
+- Delete the GitHub server by running `claude mcp remove github` and repeating the setup process with a different method
+- Make sure you're running Claude Code within the project you're currently working on to ensure the MCP configuration is properly scoped to your project
+- Check logs:
+ - Claude Code: Use `/mcp` command
+ - Claude Desktop: `ls ~/Library/Logs/Claude/` and `cat ~/Library/Logs/Claude/mcp-server-*.log` (macOS) or `%APPDATA%\Claude\logs\` (Windows)
---
-## Additional Resources
+## Important Notes
-- [Model Context Protocol Documentation](https://modelcontextprotocol.io)
-- [Claude Code MCP Documentation](https://docs.anthropic.com/en/docs/claude-code/mcp)
-- [Claude Web Integrations Support](https://support.anthropic.com/en/articles/11175166-about-custom-integrations-using-remote-mcp)
+- The npm package `@modelcontextprotocol/server-github` is deprecated as of April 2025
+- Remote server requires Streamable HTTP support (check your Claude version)
+- Configuration scopes for Claude Code:
+ - `-s user`: Available across all projects
+ - `-s project`: Shared via `.mcp.json` file
+ - Default: `local` (current project only)
diff --git a/docs/installation-guides/install-cursor.md b/docs/installation-guides/install-cursor.md
index b069addd3..654f0a788 100644
--- a/docs/installation-guides/install-cursor.md
+++ b/docs/installation-guides/install-cursor.md
@@ -1,17 +1,19 @@
# Install GitHub MCP Server in Cursor
## Prerequisites
+
1. Cursor IDE installed (latest version)
2. [GitHub Personal Access Token](https://github.com/settings/personal-access-tokens/new) with appropriate scopes
3. For local installation: [Docker](https://www.docker.com/) installed and running
## Remote Server Setup (Recommended)
-[](https://cursor.com/install-mcp?name=github&config=eyJ1cmwiOiJodHRwczovL2FwaS5naXRodWJjb3BpbG90LmNvbS9tY3AvIiwiaGVhZGVycyI6eyJBdXRob3JpemF0aW9uIjoiQmVhcmVyIFlPVVJfR0lUSFVCX1BBVCJ9LCJ0eXBlIjoiaHR0cCJ9)
+[](https://cursor.com/en/install-mcp?name=github&config=eyJ1cmwiOiJodHRwczovL2FwaS5naXRodWJjb3BpbG90LmNvbS9tY3AvIiwiaGVhZGVycyI6eyJBdXRob3JpemF0aW9uIjoiQmVhcmVyIFlPVVJfR0lUSFVCX1BBVCJ9fQ%3D%3D)
Uses GitHub's hosted server at https://api.githubcopilot.com/mcp/. Requires Cursor v0.48.0+ for Streamable HTTP support. While Cursor supports OAuth for some MCP servers, the GitHub server currently requires a Personal Access Token.
### Install steps
+
1. Click the install button above and follow the flow, or go directly to your global MCP configuration file at `~/.cursor/mcp.json` and enter the code block below
2. In Tools & Integrations > MCP tools, click the pencil icon next to "github"
3. Replace `YOUR_GITHUB_PAT` with your actual [GitHub Personal Access Token](https://github.com/settings/tokens)
@@ -35,11 +37,12 @@ Uses GitHub's hosted server at https://api.githubcopilot.com/mcp/. Requires Curs
## Local Server Setup
-[](https://cursor.com/install-mcp?name=github&config=eyJjb21tYW5kIjoiZG9ja2VyIiwiYXJncyI6WyJydW4iLCItaSIsIi0tcm0iLCItZSIsIkdJVEhVQl9QRVJTT05BTF9BQ0NFU1NfVE9LRU4iLCJnaGNyLmlvL2dpdGh1Yi9naXRodWItbWNwLXNlcnZlciJdLCJlbnYiOnsiR0lUSFVCX1BFUlNPTkFMX0FDQ0VTU19UT0tFTiI6IllPVVJfR0lUSFVCX1BHVCJ9fQ==)
+[](https://cursor.com/en/install-mcp?name=github&config=eyJjb21tYW5kIjoiZG9ja2VyIHJ1biAtaSAtLXJtIC1lIEdJVEhVQl9QRVJTT05BTF9BQ0NFU1NfVE9LRU4gZ2hjci5pby9naXRodWIvZ2l0aHViLW1jcC1zZXJ2ZXIiLCJlbnYiOnsiR0lUSFVCX1BFUlNPTkFMX0FDQ0VTU19UT0tFTiI6IllPVVJfR0lUSFVCX1BBVCJ9fQ%3D%3D)
The local GitHub MCP server runs via Docker and requires Docker Desktop to be installed and running.
### Install steps
+
1. Click the install button above and follow the flow, or go directly to your global MCP configuration file at `~/.cursor/mcp.json` and enter the code block below
2. In Tools & Integrations > MCP tools, click the pencil icon next to "github"
3. Replace `YOUR_GITHUB_PAT` with your actual [GitHub Personal Access Token](https://github.com/settings/tokens)
@@ -77,6 +80,7 @@ The local GitHub MCP server runs via Docker and requires Docker Desktop to be in
- **Project-specific**: `.cursor/mcp.json` in project root
## Verify Installation
+
1. Restart Cursor completely
2. Check for green dot in Settings β Tools & Integrations β MCP Tools
3. In chat/composer, check "Available Tools"
@@ -85,16 +89,19 @@ The local GitHub MCP server runs via Docker and requires Docker Desktop to be in
## Troubleshooting
### Remote Server Issues
+
- **Streamable HTTP not working**: Ensure you're using Cursor v0.48.0 or later
- **Authentication failures**: Verify PAT has correct scopes
- **Connection errors**: Check firewall/proxy settings
### Local Server Issues
+
- **Docker errors**: Ensure Docker Desktop is running
- **Image pull failures**: Try `docker logout ghcr.io` then retry
- **Docker not found**: Install Docker Desktop and ensure it's running
### General Issues
+
- **MCP not loading**: Restart Cursor completely after configuration
- **Invalid JSON**: Validate that json format is correct
- **Tools not appearing**: Check server shows green dot in MCP settings
diff --git a/docs/installation-guides/install-gemini-cli.md b/docs/installation-guides/install-gemini-cli.md
new file mode 100644
index 000000000..21abc8653
--- /dev/null
+++ b/docs/installation-guides/install-gemini-cli.md
@@ -0,0 +1,156 @@
+# Install GitHub MCP Server in Google Gemini CLI
+
+## Prerequisites
+
+1. Google Gemini CLI installed (see [official Gemini CLI documentation](https://github.com/google-gemini/gemini-cli))
+2. [GitHub Personal Access Token](https://github.com/settings/personal-access-tokens/new) with appropriate scopes
+3. For local installation: [Docker](https://www.docker.com/) installed and running
+
+
+Storing Your PAT Securely
+
+
+For security, avoid hardcoding your token. Create or update `~/.gemini/.env` (where `~` is your home or project directory) with your PAT:
+
+```bash
+# ~/.gemini/.env
+GITHUB_PAT=your_token_here
+```
+
+
+
+## GitHub MCP Server Configuration
+
+MCP servers for Gemini CLI are configured in its settings JSON under an `mcpServers` key.
+
+- **Global configuration**: `~/.gemini/settings.json` where `~` is your home directory
+- **Project-specific**: `.gemini/settings.json` in your project directory
+
+After securely storing your PAT, you can add the GitHub MCP server configuration to your settings file using one of the methods below. You may need to restart the Gemini CLI for changes to take effect.
+
+> **Note:** For the most up-to-date configuration options, see the [main README.md](../../README.md).
+
+### Method 1: Remote Server (Recommended)
+
+The simplest way is to use GitHub's hosted MCP server:
+
+```json
+// ~/.gemini/settings.json
+{
+ "mcpServers": {
+ "github": {
+ "httpUrl": "https://api.githubcopilot.com/mcp/",
+ "trust": true,
+ "headers": {
+ "Authorization": "Bearer $GITHUB_PAT"
+ }
+ }
+ }
+}
+```
+
+### Method 2: Local Docker
+
+With docker running, you can run the GitHub MCP server in a container:
+
+```json
+// ~/.gemini/settings.json
+{
+ "mcpServers": {
+ "github": {
+ "command": "docker",
+ "args": [
+ "run",
+ "-i",
+ "--rm",
+ "-e",
+ "GITHUB_PERSONAL_ACCESS_TOKEN",
+ "ghcr.io/github/github-mcp-server"
+ ],
+ "env": {
+ "GITHUB_PERSONAL_ACCESS_TOKEN": "$GITHUB_PAT"
+ }
+ }
+ }
+}
+```
+
+### Method 3: Binary
+
+You can download the latest binary release from the [GitHub releases page](https://github.com/github/github-mcp-server/releases) or build it from source by running `go build -o github-mcp-server ./cmd/github-mcp-server`.
+
+Then, replacing `/path/to/binary` with the actual path to your binary, configure Gemini CLI with:
+
+```json
+// ~/.gemini/settings.json
+{
+ "mcpServers": {
+ "github": {
+ "command": "/path/to/binary",
+ "args": ["stdio"],
+ "env": {
+ "GITHUB_PERSONAL_ACCESS_TOKEN": "$GITHUB_PAT"
+ }
+ }
+ }
+}
+```
+
+## Verification
+
+To verify that the GitHub MCP server has been configured, start Gemini CLI in your terminal with `gemini`, then:
+
+1. **Check MCP server status**:
+
+ ```
+ /mcp list
+ ```
+
+ ```
+ βΉConfigured MCP servers:
+
+ π’ github - Ready (96 tools, 2 prompts)
+ Tools:
+ - github__add_comment_to_pending_review
+ - github__add_issue_comment
+ - github__add_sub_issue
+ ...
+ ```
+
+2. **Test with a prompt**
+ ```
+ List my GitHub repositories
+ ```
+
+## Troubleshooting
+
+### Local Server Issues
+
+- **Docker errors**: Ensure Docker Desktop is running
+ ```bash
+ docker --version
+ ```
+- **Image pull failures**: Try `docker logout ghcr.io` then retry
+- **Docker not found**: Install Docker Desktop and ensure it's running
+
+### Authentication Issues
+
+- **Invalid PAT**: Verify your GitHub PAT has correct scopes:
+ - `repo` - Repository operations
+ - `read:packages` - Docker image access (if using Docker)
+- **Token expired**: Generate a new GitHub PAT
+
+### Configuration Issues
+
+- **Invalid JSON**: Validate your configuration:
+ ```bash
+ cat ~/.gemini/settings.json | jq .
+ ```
+- **MCP connection issues**: Check logs for connection errors:
+ ```bash
+ gemini --debug "test command"
+ ```
+
+## References
+
+- Gemini CLI Docs > [MCP Configuration Structure](https://google-gemini.github.io/gemini-cli/docs/tools/mcp-server.html#configuration-structure)
diff --git a/docs/installation-guides/install-other-copilot-ides.md b/docs/installation-guides/install-other-copilot-ides.md
index 38b48bbbd..a3200179c 100644
--- a/docs/installation-guides/install-other-copilot-ides.md
+++ b/docs/installation-guides/install-other-copilot-ides.md
@@ -12,33 +12,34 @@ Quick setup guide for the GitHub MCP server in GitHub Copilot across different I
## Visual Studio
-Requires Visual Studio 2022 version 17.14 or later.
+Requires Visual Studio 2022 version 17.14.9 or later.
### Remote Server (Recommended)
The remote GitHub MCP server is hosted by GitHub and provides automatic updates with no local setup required.
#### Configuration
-1. Go to **Tools** β **Options** β **GitHub** β **Copilot** β **MCP Servers**
+1. Create an `.mcp.json` file in your solution or %USERPROFILE% directory.
2. Add this configuration:
```json
{
"servers": {
"github": {
- "url": "https://api.githubcopilot.com/mcp/",
- "authorization_token": "Bearer YOUR_GITHUB_PAT"
+ "url": "https://api.githubcopilot.com/mcp/"
}
}
}
```
-3. Restart Visual Studio
+3. Save the file. Wait for CodeLens to update to offer a way to authenticate to the new server, activate that and pick the GitHub account to authenticate with.
+4. In the GitHub Copilot Chat window, switch to Agent mode.
+5. Activate the tool picker in the Chat window and enable one or more tools from the "github" MCP server.
### Local Server
For users who prefer to run the GitHub MCP server locally. Requires Docker installed and running.
#### Configuration
-1. Create an `.mcp.json` file in your solution directory
+1. Create an `.mcp.json` file in your solution or %USERPROFILE% directory.
2. Add this configuration:
```json
{
@@ -65,9 +66,11 @@ For users who prefer to run the GitHub MCP server locally. Requires Docker insta
}
}
```
-3. Save the file and restart Visual Studio
+3. Save the file. Wait for CodeLens to update to offer a way to provide user inputs, activate that and paste in a PAT you generate from https://github.com/settings/tokens.
+4. In the GitHub Copilot Chat window, switch to Agent mode.
+5. Activate the tool picker in the Chat window and enable one or more tools from the "github" MCP server.
-**Documentation:** [Visual Studio MCP Guide](https://learn.microsoft.com/en-us/visualstudio/ide/mcp-servers?view=vs-2022)
+**Documentation:** [Visual Studio MCP Guide](https://learn.microsoft.com/visualstudio/ide/mcp-servers)
---
diff --git a/docs/remote-server.md b/docs/remote-server.md
index 49794c605..b6f7fa61d 100644
--- a/docs/remote-server.md
+++ b/docs/remote-server.md
@@ -25,12 +25,14 @@ Below is a table of available toolsets for the remote GitHub MCP Server. Each to
| Dependabot | Dependabot tools | https://api.githubcopilot.com/mcp/x/dependabot | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-dependabot&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fdependabot%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/dependabot/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-dependabot&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fdependabot%2Freadonly%22%7D) |
| Discussions | GitHub Discussions related tools | https://api.githubcopilot.com/mcp/x/discussions | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-discussions&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fdiscussions%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/discussions/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-discussions&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fdiscussions%2Freadonly%22%7D) |
| Experiments | Experimental features that are not considered stable yet | https://api.githubcopilot.com/mcp/x/experiments | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-experiments&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fexperiments%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/experiments/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-experiments&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fexperiments%2Freadonly%22%7D) |
+| Gists | GitHub Gist related tools | https://api.githubcopilot.com/mcp/x/gists | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-gists&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fgists%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/gists/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-gists&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fgists%2Freadonly%22%7D) |
| Issues | GitHub Issues related tools | https://api.githubcopilot.com/mcp/x/issues | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-issues&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fissues%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/issues/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-issues&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fissues%2Freadonly%22%7D) |
| Notifications | GitHub Notifications related tools | https://api.githubcopilot.com/mcp/x/notifications | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-notifications&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fnotifications%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/notifications/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-notifications&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fnotifications%2Freadonly%22%7D) |
| Organizations | GitHub Organization related tools | https://api.githubcopilot.com/mcp/x/orgs | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-orgs&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Forgs%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/orgs/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-orgs&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Forgs%2Freadonly%22%7D) |
| Pull Requests | GitHub Pull Request related tools | https://api.githubcopilot.com/mcp/x/pull_requests | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-pull_requests&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fpull_requests%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/pull_requests/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-pull_requests&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fpull_requests%2Freadonly%22%7D) |
| Repositories | GitHub Repository related tools | https://api.githubcopilot.com/mcp/x/repos | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-repos&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Frepos%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/repos/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-repos&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Frepos%2Freadonly%22%7D) |
| Secret Protection | Secret protection related tools, such as GitHub Secret Scanning | https://api.githubcopilot.com/mcp/x/secret_protection | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-secret_protection&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fsecret_protection%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/secret_protection/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-secret_protection&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fsecret_protection%2Freadonly%22%7D) |
+| Security Advisories | Security advisories related tools | https://api.githubcopilot.com/mcp/x/security_advisories | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-security_advisories&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fsecurity_advisories%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/security_advisories/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-security_advisories&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fsecurity_advisories%2Freadonly%22%7D) |
| Users | GitHub User related tools | https://api.githubcopilot.com/mcp/x/users | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-users&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fusers%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/users/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-users&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fusers%2Freadonly%22%7D) |
diff --git a/docs/testing.md b/docs/testing.md
index dbdc3e080..226660e9d 100644
--- a/docs/testing.md
+++ b/docs/testing.md
@@ -22,7 +22,7 @@ This project uses a combination of unit tests and end-to-end (e2e) tests to ensu
## toolsnaps: Tool Schema Snapshots
- The `toolsnaps` utility ensures that the JSON schema for each tool does not change unexpectedly.
-- Snapshots are stored in `__toolsnaps__/*.snap` files , where `*` represents the name of the tool
+- Snapshots are stored in `__toolsnaps__/*.snap` files, where `*` represents the name of the tool
- When running tests, the current tool schema is compared to the snapshot. If there is a difference, the test will fail and show a diff.
- If you intentionally change a tool's schema, update the snapshots by running tests with the environment variable: `UPDATE_TOOLSNAPS=true go test ./...`
- In CI (when `GITHUB_ACTIONS=true`), missing snapshots will cause a test failure to ensure snapshots are always
diff --git a/e2e/e2e_test.go b/e2e/e2e_test.go
index 64c5729ba..24cfc7096 100644
--- a/e2e/e2e_test.go
+++ b/e2e/e2e_test.go
@@ -18,7 +18,7 @@ import (
"github.com/github/github-mcp-server/internal/ghmcp"
"github.com/github/github-mcp-server/pkg/github"
"github.com/github/github-mcp-server/pkg/translations"
- gogithub "github.com/google/go-github/v73/github"
+ gogithub "github.com/google/go-github/v74/github"
mcpClient "github.com/mark3labs/mcp-go/client"
"github.com/mark3labs/mcp-go/mcp"
"github.com/stretchr/testify/require"
diff --git a/github-mcp-server b/github-mcp-server
new file mode 100755
index 000000000..864242c24
Binary files /dev/null and b/github-mcp-server differ
diff --git a/go.mod b/go.mod
index 3df6bf3d5..1f215fdc0 100644
--- a/go.mod
+++ b/go.mod
@@ -3,21 +3,24 @@ module github.com/github/github-mcp-server
go 1.23.7
require (
- github.com/google/go-github/v73 v73.0.0
+ github.com/google/go-github/v74 v74.0.0
github.com/josephburnett/jd v1.9.2
- github.com/mark3labs/mcp-go v0.32.0
+ github.com/mark3labs/mcp-go v0.36.0
github.com/migueleliasweb/go-github-mock v1.3.0
- github.com/sirupsen/logrus v1.9.3
github.com/spf13/cobra v1.9.1
github.com/spf13/viper v1.20.1
- github.com/stretchr/testify v1.10.0
+ github.com/stretchr/testify v1.11.1
)
require (
+ github.com/bahlo/generic-list-go v0.2.0 // indirect
+ github.com/buger/jsonparser v1.1.1 // indirect
github.com/go-openapi/jsonpointer v0.19.5 // indirect
github.com/go-openapi/swag v0.21.1 // indirect
+ github.com/invopop/jsonschema v0.13.0 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/mailru/easyjson v0.7.7 // indirect
+ github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect
github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82 // indirect
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
diff --git a/go.sum b/go.sum
index d77cdf0d9..72730b820 100644
--- a/go.sum
+++ b/go.sum
@@ -1,3 +1,7 @@
+github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
+github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=
+github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs=
+github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -20,8 +24,8 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/go-github/v71 v71.0.0 h1:Zi16OymGKZZMm8ZliffVVJ/Q9YZreDKONCr+WUd0Z30=
github.com/google/go-github/v71 v71.0.0/go.mod h1:URZXObp2BLlMjwu0O8g4y6VBneUj2bCHgnI8FfgZ51M=
-github.com/google/go-github/v73 v73.0.0 h1:aR+Utnh+Y4mMkS+2qLQwcQ/cF9mOTpdwnzlaw//rG24=
-github.com/google/go-github/v73 v73.0.0/go.mod h1:fa6w8+/V+edSU0muqdhCVY7Beh1M8F1IlQPZIANKIYw=
+github.com/google/go-github/v74 v74.0.0 h1:yZcddTUn8DPbj11GxnMrNiAnXH14gNs559AsUpNpPgM=
+github.com/google/go-github/v74 v74.0.0/go.mod h1:ubn/YdyftV80VPSI26nSJvaEsTOnsjrxG3o9kJhcyak=
github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
@@ -30,6 +34,8 @@ github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI=
github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
+github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E=
+github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0=
github.com/josephburnett/jd v1.9.2 h1:ECJRRFXCCqbtidkAHckHGSZm/JIaAxS1gygHLF8MI5Y=
github.com/josephburnett/jd v1.9.2/go.mod h1:bImDr8QXpxMb3SD+w1cDRHp97xP6UwI88xUAuxwDQfM=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
@@ -47,8 +53,8 @@ github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN
github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
-github.com/mark3labs/mcp-go v0.32.0 h1:fgwmbfL2gbd67obg57OfV2Dnrhs1HtSdlY/i5fn7MU8=
-github.com/mark3labs/mcp-go v0.32.0/go.mod h1:rXqOudj/djTORU/ThxYx8fqEVj/5pvTuuebQ2RC7uk4=
+github.com/mark3labs/mcp-go v0.36.0 h1:rIZaijrRYPeSbJG8/qNDe0hWlGrCJ7FWHNMz2SQpTis=
+github.com/mark3labs/mcp-go v0.36.0/go.mod h1:T7tUa2jO6MavG+3P25Oy/jR7iCeJPHImCZHRymCn39g=
github.com/migueleliasweb/go-github-mock v1.3.0 h1:2sVP9JEMB2ubQw1IKto3/fzF51oFC6eVWOOFDgQoq88=
github.com/migueleliasweb/go-github-mock v1.3.0/go.mod h1:ipQhV8fTcj/G6m7BKzin08GaJ/3B5/SonRAkgrk0zCY=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
@@ -66,8 +72,6 @@ github.com/shurcooL/githubv4 v0.0.0-20240727222349-48295856cce7 h1:cYCy18SHPKRkv
github.com/shurcooL/githubv4 v0.0.0-20240727222349-48295856cce7/go.mod h1:zqMwyHmnN/eDOZOdiTohqIUKUrTFX62PNlu7IJdu0q8=
github.com/shurcooL/graphql v0.0.0-20230722043721-ed46e5a46466 h1:17JxqqJY66GmZVHkmAsGEkcIu0oCe3AM420QDgGwZx0=
github.com/shurcooL/graphql v0.0.0-20230722043721-ed46e5a46466/go.mod h1:9dIRpgIY7hVhoqfe0/FcYp0bpInZaT7dc3BYOprrIUE=
-github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
-github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
github.com/spf13/afero v1.14.0 h1:9tH6MapGnn/j0eb0yIXiLjERO8RB6xIVZRDCX7PtqWA=
@@ -83,11 +87,12 @@ github.com/spf13/viper v1.20.1/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqj
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
-github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
-github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
-github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
+github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
+github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
+github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc=
+github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw=
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82 h1:BHyfKlQyqbsFN5p3IfnEUduWvb9is428/nNb5L3U01M=
@@ -98,7 +103,6 @@ golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY=
golang.org/x/oauth2 v0.29.0 h1:WdYw2tdTK1S8olAzWHdgeqfy+Mtm9XNhv/xJsY65d98=
golang.org/x/oauth2 v0.29.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8=
-golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
diff --git a/internal/ghmcp/server.go b/internal/ghmcp/server.go
index d993b130a..7ad71532f 100644
--- a/internal/ghmcp/server.go
+++ b/internal/ghmcp/server.go
@@ -5,6 +5,7 @@ import (
"fmt"
"io"
"log"
+ "log/slog"
"net/http"
"net/url"
"os"
@@ -17,11 +18,10 @@ import (
mcplog "github.com/github/github-mcp-server/pkg/log"
"github.com/github/github-mcp-server/pkg/raw"
"github.com/github/github-mcp-server/pkg/translations"
- gogithub "github.com/google/go-github/v73/github"
+ gogithub "github.com/google/go-github/v74/github"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
"github.com/shurcooL/githubv4"
- "github.com/sirupsen/logrus"
)
type MCPServerConfig struct {
@@ -47,8 +47,13 @@ type MCPServerConfig struct {
// Translator provides translated text for the server tooling
Translator translations.TranslationHelperFunc
+
+ // Content window size
+ ContentWindowSize int
}
+const stdioServerLogPrefix = "stdioserver"
+
func NewMCPServer(cfg MCPServerConfig) (*server.MCPServer, error) {
apiHost, err := parseAPIHost(cfg.Host)
if err != nil {
@@ -130,7 +135,7 @@ func NewMCPServer(cfg MCPServerConfig) (*server.MCPServer, error) {
}
// Create default toolsets
- tsg := github.DefaultToolsetGroup(cfg.ReadOnly, getClient, getGQLClient, getRawClient, cfg.Translator)
+ tsg := github.DefaultToolsetGroup(cfg.ReadOnly, getClient, getGQLClient, getRawClient, cfg.Translator, cfg.ContentWindowSize)
err = tsg.EnableToolsets(enabledToolsets)
if err != nil {
@@ -178,6 +183,9 @@ type StdioServerConfig struct {
// Path to the log file if not stderr
LogFilePath string
+
+ // Content window size
+ ContentWindowSize int
}
// RunStdioServer is not concurrent safe.
@@ -189,13 +197,14 @@ func RunStdioServer(cfg StdioServerConfig) error {
t, dumpTranslations := translations.TranslationHelper()
ghServer, err := NewMCPServer(MCPServerConfig{
- Version: cfg.Version,
- Host: cfg.Host,
- Token: cfg.Token,
- EnabledToolsets: cfg.EnabledToolsets,
- DynamicToolsets: cfg.DynamicToolsets,
- ReadOnly: cfg.ReadOnly,
- Translator: t,
+ Version: cfg.Version,
+ Host: cfg.Host,
+ Token: cfg.Token,
+ EnabledToolsets: cfg.EnabledToolsets,
+ DynamicToolsets: cfg.DynamicToolsets,
+ ReadOnly: cfg.ReadOnly,
+ Translator: t,
+ ContentWindowSize: cfg.ContentWindowSize,
})
if err != nil {
return fmt.Errorf("failed to create MCP server: %w", err)
@@ -203,17 +212,22 @@ func RunStdioServer(cfg StdioServerConfig) error {
stdioServer := server.NewStdioServer(ghServer)
- logrusLogger := logrus.New()
+ var slogHandler slog.Handler
+ var logOutput io.Writer
if cfg.LogFilePath != "" {
file, err := os.OpenFile(cfg.LogFilePath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0600)
if err != nil {
return fmt.Errorf("failed to open log file: %w", err)
}
-
- logrusLogger.SetLevel(logrus.DebugLevel)
- logrusLogger.SetOutput(file)
- }
- stdLogger := log.New(logrusLogger.Writer(), "stdioserver", 0)
+ logOutput = file
+ slogHandler = slog.NewTextHandler(logOutput, &slog.HandlerOptions{Level: slog.LevelDebug})
+ } else {
+ logOutput = os.Stderr
+ slogHandler = slog.NewTextHandler(logOutput, &slog.HandlerOptions{Level: slog.LevelInfo})
+ }
+ logger := slog.New(slogHandler)
+ logger.Info("starting server", "version", cfg.Version, "host", cfg.Host, "dynamicToolsets", cfg.DynamicToolsets, "readOnly", cfg.ReadOnly)
+ stdLogger := log.New(logOutput, stdioServerLogPrefix, 0)
stdioServer.SetErrorLogger(stdLogger)
if cfg.ExportTranslations {
@@ -227,7 +241,7 @@ func RunStdioServer(cfg StdioServerConfig) error {
in, out := io.Reader(os.Stdin), io.Writer(os.Stdout)
if cfg.EnableCommandLogging {
- loggedIO := mcplog.NewIOLogger(in, out, logrusLogger)
+ loggedIO := mcplog.NewIOLogger(in, out, logger)
in, out = loggedIO, loggedIO
}
// enable GitHub errors in the context
@@ -241,9 +255,10 @@ func RunStdioServer(cfg StdioServerConfig) error {
// Wait for shutdown signal
select {
case <-ctx.Done():
- logrusLogger.Infof("shutting down server...")
+ logger.Info("shutting down server", "signal", "context done")
case err := <-errC:
if err != nil {
+ logger.Error("error running server", "error", err)
return fmt.Errorf("error running server: %w", err)
}
}
diff --git a/internal/profiler/profiler.go b/internal/profiler/profiler.go
new file mode 100644
index 000000000..1cfb7ffae
--- /dev/null
+++ b/internal/profiler/profiler.go
@@ -0,0 +1,215 @@
+package profiler
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "runtime"
+ "strconv"
+ "time"
+
+ "log/slog"
+ "math"
+)
+
+// Profile represents performance metrics for an operation
+type Profile struct {
+ Operation string `json:"operation"`
+ Duration time.Duration `json:"duration_ns"`
+ MemoryBefore uint64 `json:"memory_before_bytes"`
+ MemoryAfter uint64 `json:"memory_after_bytes"`
+ MemoryDelta int64 `json:"memory_delta_bytes"`
+ LinesCount int `json:"lines_count,omitempty"`
+ BytesCount int64 `json:"bytes_count,omitempty"`
+ Timestamp time.Time `json:"timestamp"`
+}
+
+// String returns a human-readable representation of the profile
+func (p *Profile) String() string {
+ return fmt.Sprintf("[%s] %s: duration=%v, memory_delta=%+dB, lines=%d, bytes=%d",
+ p.Timestamp.Format("15:04:05.000"),
+ p.Operation,
+ p.Duration,
+ p.MemoryDelta,
+ p.LinesCount,
+ p.BytesCount,
+ )
+}
+
+func safeMemoryDelta(after, before uint64) int64 {
+ if after > math.MaxInt64 || before > math.MaxInt64 {
+ if after >= before {
+ diff := after - before
+ if diff > math.MaxInt64 {
+ return math.MaxInt64
+ }
+ return int64(diff)
+ }
+ diff := before - after
+ if diff > math.MaxInt64 {
+ return -math.MaxInt64
+ }
+ return -int64(diff)
+ }
+
+ return int64(after) - int64(before)
+}
+
+// Profiler provides minimal performance profiling capabilities
+type Profiler struct {
+ logger *slog.Logger
+ enabled bool
+}
+
+// New creates a new Profiler instance
+func New(logger *slog.Logger, enabled bool) *Profiler {
+ return &Profiler{
+ logger: logger,
+ enabled: enabled,
+ }
+}
+
+// ProfileFunc profiles a function execution
+func (p *Profiler) ProfileFunc(ctx context.Context, operation string, fn func() error) (*Profile, error) {
+ if !p.enabled {
+ return nil, fn()
+ }
+
+ profile := &Profile{
+ Operation: operation,
+ Timestamp: time.Now(),
+ }
+
+ var memBefore runtime.MemStats
+ runtime.ReadMemStats(&memBefore)
+ profile.MemoryBefore = memBefore.Alloc
+
+ start := time.Now()
+ err := fn()
+ profile.Duration = time.Since(start)
+
+ var memAfter runtime.MemStats
+ runtime.ReadMemStats(&memAfter)
+ profile.MemoryAfter = memAfter.Alloc
+ profile.MemoryDelta = safeMemoryDelta(memAfter.Alloc, memBefore.Alloc)
+
+ if p.logger != nil {
+ p.logger.InfoContext(ctx, "Performance profile", "profile", profile.String())
+ }
+
+ return profile, err
+}
+
+// ProfileFuncWithMetrics profiles a function execution and captures additional metrics
+func (p *Profiler) ProfileFuncWithMetrics(ctx context.Context, operation string, fn func() (int, int64, error)) (*Profile, error) {
+ if !p.enabled {
+ _, _, err := fn()
+ return nil, err
+ }
+
+ profile := &Profile{
+ Operation: operation,
+ Timestamp: time.Now(),
+ }
+
+ var memBefore runtime.MemStats
+ runtime.ReadMemStats(&memBefore)
+ profile.MemoryBefore = memBefore.Alloc
+
+ start := time.Now()
+ lines, bytes, err := fn()
+ profile.Duration = time.Since(start)
+ profile.LinesCount = lines
+ profile.BytesCount = bytes
+
+ var memAfter runtime.MemStats
+ runtime.ReadMemStats(&memAfter)
+ profile.MemoryAfter = memAfter.Alloc
+ profile.MemoryDelta = safeMemoryDelta(memAfter.Alloc, memBefore.Alloc)
+
+ if p.logger != nil {
+ p.logger.InfoContext(ctx, "Performance profile", "profile", profile.String())
+ }
+
+ return profile, err
+}
+
+// Start begins timing an operation and returns a function to complete the profiling
+func (p *Profiler) Start(ctx context.Context, operation string) func(lines int, bytes int64) *Profile {
+ if !p.enabled {
+ return func(int, int64) *Profile { return nil }
+ }
+
+ profile := &Profile{
+ Operation: operation,
+ Timestamp: time.Now(),
+ }
+
+ var memBefore runtime.MemStats
+ runtime.ReadMemStats(&memBefore)
+ profile.MemoryBefore = memBefore.Alloc
+
+ start := time.Now()
+
+ return func(lines int, bytes int64) *Profile {
+ profile.Duration = time.Since(start)
+ profile.LinesCount = lines
+ profile.BytesCount = bytes
+
+ var memAfter runtime.MemStats
+ runtime.ReadMemStats(&memAfter)
+ profile.MemoryAfter = memAfter.Alloc
+ profile.MemoryDelta = safeMemoryDelta(memAfter.Alloc, memBefore.Alloc)
+
+ if p.logger != nil {
+ p.logger.InfoContext(ctx, "Performance profile", "profile", profile.String())
+ }
+
+ return profile
+ }
+}
+
+var globalProfiler *Profiler
+
+// IsProfilingEnabled checks if profiling is enabled via environment variables
+func IsProfilingEnabled() bool {
+ if enabled, err := strconv.ParseBool(os.Getenv("GITHUB_MCP_PROFILING_ENABLED")); err == nil {
+ return enabled
+ }
+ return false
+}
+
+// Init initializes the global profiler
+func Init(logger *slog.Logger, enabled bool) {
+ globalProfiler = New(logger, enabled)
+}
+
+// InitFromEnv initializes the global profiler using environment variables
+func InitFromEnv(logger *slog.Logger) {
+ globalProfiler = New(logger, IsProfilingEnabled())
+}
+
+// ProfileFunc profiles a function using the global profiler
+func ProfileFunc(ctx context.Context, operation string, fn func() error) (*Profile, error) {
+ if globalProfiler == nil {
+ return nil, fn()
+ }
+ return globalProfiler.ProfileFunc(ctx, operation, fn)
+}
+
+// ProfileFuncWithMetrics profiles a function with metrics using the global profiler
+func ProfileFuncWithMetrics(ctx context.Context, operation string, fn func() (int, int64, error)) (*Profile, error) {
+ if globalProfiler == nil {
+ _, _, err := fn()
+ return nil, err
+ }
+ return globalProfiler.ProfileFuncWithMetrics(ctx, operation, fn)
+}
+
+// Start begins timing using the global profiler
+func Start(ctx context.Context, operation string) func(int, int64) *Profile {
+ if globalProfiler == nil {
+ return func(int, int64) *Profile { return nil }
+ }
+ return globalProfiler.Start(ctx, operation)
+}
diff --git a/pkg/buffer/buffer.go b/pkg/buffer/buffer.go
new file mode 100644
index 000000000..546b5324c
--- /dev/null
+++ b/pkg/buffer/buffer.go
@@ -0,0 +1,69 @@
+package buffer
+
+import (
+ "bufio"
+ "fmt"
+ "net/http"
+ "strings"
+)
+
+// ProcessResponseAsRingBufferToEnd reads the body of an HTTP response line by line,
+// storing only the last maxJobLogLines lines using a ring buffer (sliding window).
+// This efficiently retains the most recent lines, overwriting older ones as needed.
+//
+// Parameters:
+//
+// httpResp: The HTTP response whose body will be read.
+// maxJobLogLines: The maximum number of log lines to retain.
+//
+// Returns:
+//
+// string: The concatenated log lines (up to maxJobLogLines), separated by newlines.
+// int: The total number of lines read from the response.
+// *http.Response: The original HTTP response.
+// error: Any error encountered during reading.
+//
+// The function uses a ring buffer to efficiently store only the last maxJobLogLines lines.
+// If the response contains more lines than maxJobLogLines, only the most recent lines are kept.
+func ProcessResponseAsRingBufferToEnd(httpResp *http.Response, maxJobLogLines int) (string, int, *http.Response, error) {
+ lines := make([]string, maxJobLogLines)
+ validLines := make([]bool, maxJobLogLines)
+ totalLines := 0
+ writeIndex := 0
+
+ scanner := bufio.NewScanner(httpResp.Body)
+ scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
+
+ for scanner.Scan() {
+ line := scanner.Text()
+ totalLines++
+
+ lines[writeIndex] = line
+ validLines[writeIndex] = true
+ writeIndex = (writeIndex + 1) % maxJobLogLines
+ }
+
+ if err := scanner.Err(); err != nil {
+ return "", 0, httpResp, fmt.Errorf("failed to read log content: %w", err)
+ }
+
+ var result []string
+ linesInBuffer := totalLines
+ if linesInBuffer > maxJobLogLines {
+ linesInBuffer = maxJobLogLines
+ }
+
+ startIndex := 0
+ if totalLines > maxJobLogLines {
+ startIndex = writeIndex
+ }
+
+ for i := 0; i < linesInBuffer; i++ {
+ idx := (startIndex + i) % maxJobLogLines
+ if validLines[idx] {
+ result = append(result, lines[idx])
+ }
+ }
+
+ return strings.Join(result, "\n"), totalLines, httpResp, nil
+}
diff --git a/pkg/errors/error.go b/pkg/errors/error.go
index c89ab2d79..1e15021d2 100644
--- a/pkg/errors/error.go
+++ b/pkg/errors/error.go
@@ -4,7 +4,7 @@ import (
"context"
"fmt"
- "github.com/google/go-github/v73/github"
+ "github.com/google/go-github/v74/github"
"github.com/mark3labs/mcp-go/mcp"
)
diff --git a/pkg/errors/error_test.go b/pkg/errors/error_test.go
index 3498e3d8a..6f7fc0a3e 100644
--- a/pkg/errors/error_test.go
+++ b/pkg/errors/error_test.go
@@ -6,7 +6,7 @@ import (
"net/http"
"testing"
- "github.com/google/go-github/v73/github"
+ "github.com/google/go-github/v74/github"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
diff --git a/pkg/github/__toolsnaps__/add_sub_issue.snap b/pkg/github/__toolsnaps__/add_sub_issue.snap
new file mode 100644
index 000000000..2d462bcaf
--- /dev/null
+++ b/pkg/github/__toolsnaps__/add_sub_issue.snap
@@ -0,0 +1,39 @@
+{
+ "annotations": {
+ "title": "Add sub-issue",
+ "readOnlyHint": false
+ },
+ "description": "Add a sub-issue to a parent issue in a GitHub repository.",
+ "inputSchema": {
+ "properties": {
+ "issue_number": {
+ "description": "The number of the parent issue",
+ "type": "number"
+ },
+ "owner": {
+ "description": "Repository owner",
+ "type": "string"
+ },
+ "replace_parent": {
+ "description": "When true, replaces the sub-issue's current parent issue",
+ "type": "boolean"
+ },
+ "repo": {
+ "description": "Repository name",
+ "type": "string"
+ },
+ "sub_issue_id": {
+ "description": "The ID of the sub-issue to add. ID is not the same as issue number",
+ "type": "number"
+ }
+ },
+ "required": [
+ "owner",
+ "repo",
+ "issue_number",
+ "sub_issue_id"
+ ],
+ "type": "object"
+ },
+ "name": "add_sub_issue"
+}
\ No newline at end of file
diff --git a/pkg/github/__toolsnaps__/create_issue.snap b/pkg/github/__toolsnaps__/create_issue.snap
index f065b0183..d11c41c0e 100644
--- a/pkg/github/__toolsnaps__/create_issue.snap
+++ b/pkg/github/__toolsnaps__/create_issue.snap
@@ -39,6 +39,10 @@
"title": {
"description": "Issue title",
"type": "string"
+ },
+ "type": {
+ "description": "Type of this issue",
+ "type": "string"
}
},
"required": [
diff --git a/pkg/github/__toolsnaps__/create_repository.snap b/pkg/github/__toolsnaps__/create_repository.snap
index aaba75f3c..6ed2dbf41 100644
--- a/pkg/github/__toolsnaps__/create_repository.snap
+++ b/pkg/github/__toolsnaps__/create_repository.snap
@@ -3,7 +3,7 @@
"title": "Create repository",
"readOnlyHint": false
},
- "description": "Create a new GitHub repository in your account",
+ "description": "Create a new GitHub repository in your account or specified organization",
"inputSchema": {
"properties": {
"autoInit": {
@@ -18,6 +18,10 @@
"description": "Repository name",
"type": "string"
},
+ "organization": {
+ "description": "Organization to create the repository in (omit to create in your personal account)",
+ "type": "string"
+ },
"private": {
"description": "Whether repo should be private",
"type": "boolean"
diff --git a/pkg/github/__toolsnaps__/get_commit.snap b/pkg/github/__toolsnaps__/get_commit.snap
index af0038110..1c2ecc9a3 100644
--- a/pkg/github/__toolsnaps__/get_commit.snap
+++ b/pkg/github/__toolsnaps__/get_commit.snap
@@ -6,6 +6,11 @@
"description": "Get details for a commit from a GitHub repository",
"inputSchema": {
"properties": {
+ "include_diff": {
+ "default": true,
+ "description": "Whether to include file diffs and stats in the response. Default is true.",
+ "type": "boolean"
+ },
"owner": {
"description": "Repository owner",
"type": "string"
diff --git a/pkg/github/__toolsnaps__/get_release_by_tag.snap b/pkg/github/__toolsnaps__/get_release_by_tag.snap
new file mode 100644
index 000000000..c96d3c30a
--- /dev/null
+++ b/pkg/github/__toolsnaps__/get_release_by_tag.snap
@@ -0,0 +1,30 @@
+{
+ "annotations": {
+ "title": "Get a release by tag name",
+ "readOnlyHint": true
+ },
+ "description": "Get a specific release by its tag name in a GitHub repository",
+ "inputSchema": {
+ "properties": {
+ "owner": {
+ "description": "Repository owner",
+ "type": "string"
+ },
+ "repo": {
+ "description": "Repository name",
+ "type": "string"
+ },
+ "tag": {
+ "description": "Tag name (e.g., 'v1.0.0')",
+ "type": "string"
+ }
+ },
+ "required": [
+ "owner",
+ "repo",
+ "tag"
+ ],
+ "type": "object"
+ },
+ "name": "get_release_by_tag"
+}
\ No newline at end of file
diff --git a/pkg/github/__toolsnaps__/get_team_members.snap b/pkg/github/__toolsnaps__/get_team_members.snap
new file mode 100644
index 000000000..2d91bb5ea
--- /dev/null
+++ b/pkg/github/__toolsnaps__/get_team_members.snap
@@ -0,0 +1,25 @@
+{
+ "annotations": {
+ "title": "Get team members",
+ "readOnlyHint": true
+ },
+ "description": "Get member usernames of a specific team in an organization. Limited to organizations accessible with current credentials",
+ "inputSchema": {
+ "properties": {
+ "org": {
+ "description": "Organization login (owner) that contains the team.",
+ "type": "string"
+ },
+ "team_slug": {
+ "description": "Team slug",
+ "type": "string"
+ }
+ },
+ "required": [
+ "org",
+ "team_slug"
+ ],
+ "type": "object"
+ },
+ "name": "get_team_members"
+}
\ No newline at end of file
diff --git a/pkg/github/__toolsnaps__/get_teams.snap b/pkg/github/__toolsnaps__/get_teams.snap
new file mode 100644
index 000000000..39ed4db35
--- /dev/null
+++ b/pkg/github/__toolsnaps__/get_teams.snap
@@ -0,0 +1,17 @@
+{
+ "annotations": {
+ "title": "Get teams",
+ "readOnlyHint": true
+ },
+ "description": "Get details of the teams the user is a member of. Limited to organizations accessible with current credentials",
+ "inputSchema": {
+ "properties": {
+ "user": {
+ "description": "Username to get teams for. If not provided, uses the authenticated user.",
+ "type": "string"
+ }
+ },
+ "type": "object"
+ },
+ "name": "get_teams"
+}
\ No newline at end of file
diff --git a/pkg/github/__toolsnaps__/list_issue_types.snap b/pkg/github/__toolsnaps__/list_issue_types.snap
new file mode 100644
index 000000000..93c3e51d9
--- /dev/null
+++ b/pkg/github/__toolsnaps__/list_issue_types.snap
@@ -0,0 +1,20 @@
+{
+ "annotations": {
+ "title": "List available issue types",
+ "readOnlyHint": true
+ },
+ "description": "List supported issue types for repository owner (organization).",
+ "inputSchema": {
+ "properties": {
+ "owner": {
+ "description": "The organization owner of the repository",
+ "type": "string"
+ }
+ },
+ "required": [
+ "owner"
+ ],
+ "type": "object"
+ },
+ "name": "list_issue_types"
+}
\ No newline at end of file
diff --git a/pkg/github/__toolsnaps__/list_issues.snap b/pkg/github/__toolsnaps__/list_issues.snap
index 4fe155f09..5475988c2 100644
--- a/pkg/github/__toolsnaps__/list_issues.snap
+++ b/pkg/github/__toolsnaps__/list_issues.snap
@@ -3,14 +3,18 @@
"title": "List issues",
"readOnlyHint": true
},
- "description": "List issues in a GitHub repository.",
+ "description": "List issues in a GitHub repository. For pagination, use the 'endCursor' from the previous response's 'pageInfo' in the 'after' parameter.",
"inputSchema": {
"properties": {
+ "after": {
+ "description": "Cursor for pagination. Use the endCursor from the previous page's PageInfo for GraphQL APIs.",
+ "type": "string"
+ },
"direction": {
- "description": "Sort direction",
+ "description": "Order direction. If provided, the 'orderBy' also needs to be provided.",
"enum": [
- "asc",
- "desc"
+ "ASC",
+ "DESC"
],
"type": "string"
},
@@ -21,15 +25,19 @@
},
"type": "array"
},
+ "orderBy": {
+ "description": "Order issues by field. If provided, the 'direction' also needs to be provided.",
+ "enum": [
+ "CREATED_AT",
+ "UPDATED_AT",
+ "COMMENTS"
+ ],
+ "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,
@@ -44,21 +52,11 @@
"description": "Filter by date (ISO 8601 timestamp)",
"type": "string"
},
- "sort": {
- "description": "Sort order",
- "enum": [
- "created",
- "updated",
- "comments"
- ],
- "type": "string"
- },
"state": {
- "description": "Filter by state",
+ "description": "Filter by state, by default both open and closed issues are returned when not provided",
"enum": [
- "open",
- "closed",
- "all"
+ "OPEN",
+ "CLOSED"
],
"type": "string"
}
diff --git a/pkg/github/__toolsnaps__/list_sub_issues.snap b/pkg/github/__toolsnaps__/list_sub_issues.snap
new file mode 100644
index 000000000..70640e270
--- /dev/null
+++ b/pkg/github/__toolsnaps__/list_sub_issues.snap
@@ -0,0 +1,38 @@
+{
+ "annotations": {
+ "title": "List sub-issues",
+ "readOnlyHint": true
+ },
+ "description": "List sub-issues for a specific issue in a GitHub repository.",
+ "inputSchema": {
+ "properties": {
+ "issue_number": {
+ "description": "Issue number",
+ "type": "number"
+ },
+ "owner": {
+ "description": "Repository owner",
+ "type": "string"
+ },
+ "page": {
+ "description": "Page number for pagination (default: 1)",
+ "type": "number"
+ },
+ "per_page": {
+ "description": "Number of results per page (max 100, default: 30)",
+ "type": "number"
+ },
+ "repo": {
+ "description": "Repository name",
+ "type": "string"
+ }
+ },
+ "required": [
+ "owner",
+ "repo",
+ "issue_number"
+ ],
+ "type": "object"
+ },
+ "name": "list_sub_issues"
+}
\ No newline at end of file
diff --git a/pkg/github/__toolsnaps__/remove_sub_issue.snap b/pkg/github/__toolsnaps__/remove_sub_issue.snap
new file mode 100644
index 000000000..a29020099
--- /dev/null
+++ b/pkg/github/__toolsnaps__/remove_sub_issue.snap
@@ -0,0 +1,35 @@
+{
+ "annotations": {
+ "title": "Remove sub-issue",
+ "readOnlyHint": false
+ },
+ "description": "Remove a sub-issue from a parent issue in a GitHub repository.",
+ "inputSchema": {
+ "properties": {
+ "issue_number": {
+ "description": "The number of the parent issue",
+ "type": "number"
+ },
+ "owner": {
+ "description": "Repository owner",
+ "type": "string"
+ },
+ "repo": {
+ "description": "Repository name",
+ "type": "string"
+ },
+ "sub_issue_id": {
+ "description": "The ID of the sub-issue to remove. ID is not the same as issue number",
+ "type": "number"
+ }
+ },
+ "required": [
+ "owner",
+ "repo",
+ "issue_number",
+ "sub_issue_id"
+ ],
+ "type": "object"
+ },
+ "name": "remove_sub_issue"
+}
\ No newline at end of file
diff --git a/pkg/github/__toolsnaps__/reprioritize_sub_issue.snap b/pkg/github/__toolsnaps__/reprioritize_sub_issue.snap
new file mode 100644
index 000000000..43c258b33
--- /dev/null
+++ b/pkg/github/__toolsnaps__/reprioritize_sub_issue.snap
@@ -0,0 +1,43 @@
+{
+ "annotations": {
+ "title": "Reprioritize sub-issue",
+ "readOnlyHint": false
+ },
+ "description": "Reprioritize a sub-issue to a different position in the parent issue's sub-issue list.",
+ "inputSchema": {
+ "properties": {
+ "after_id": {
+ "description": "The ID of the sub-issue to be prioritized after (either after_id OR before_id should be specified)",
+ "type": "number"
+ },
+ "before_id": {
+ "description": "The ID of the sub-issue to be prioritized before (either after_id OR before_id should be specified)",
+ "type": "number"
+ },
+ "issue_number": {
+ "description": "The number of the parent issue",
+ "type": "number"
+ },
+ "owner": {
+ "description": "Repository owner",
+ "type": "string"
+ },
+ "repo": {
+ "description": "Repository name",
+ "type": "string"
+ },
+ "sub_issue_id": {
+ "description": "The ID of the sub-issue to reprioritize. ID is not the same as issue number",
+ "type": "number"
+ }
+ },
+ "required": [
+ "owner",
+ "repo",
+ "issue_number",
+ "sub_issue_id"
+ ],
+ "type": "object"
+ },
+ "name": "reprioritize_sub_issue"
+}
\ No newline at end of file
diff --git a/pkg/github/__toolsnaps__/search_code.snap b/pkg/github/__toolsnaps__/search_code.snap
index c85d6674d..4ef40c5f8 100644
--- a/pkg/github/__toolsnaps__/search_code.snap
+++ b/pkg/github/__toolsnaps__/search_code.snap
@@ -3,11 +3,11 @@
"title": "Search code",
"readOnlyHint": true
},
- "description": "Search for code across GitHub repositories",
+ "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": {
"order": {
- "description": "Sort order",
+ "description": "Sort order for results",
"enum": [
"asc",
"desc"
@@ -25,8 +25,8 @@
"minimum": 1,
"type": "number"
},
- "q": {
- "description": "Search query using GitHub code search syntax",
+ "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"
},
"sort": {
@@ -35,7 +35,7 @@
}
},
"required": [
- "q"
+ "query"
],
"type": "object"
},
diff --git a/pkg/github/__toolsnaps__/search_issues.snap b/pkg/github/__toolsnaps__/search_issues.snap
index 7db502d94..bf1982411 100644
--- a/pkg/github/__toolsnaps__/search_issues.snap
+++ b/pkg/github/__toolsnaps__/search_issues.snap
@@ -15,7 +15,7 @@
"type": "string"
},
"owner": {
- "description": "Optional repository owner. If provided with repo, only notifications for this repository are listed.",
+ "description": "Optional repository owner. If provided with repo, only issues for this repository are listed.",
"type": "string"
},
"page": {
@@ -34,7 +34,7 @@
"type": "string"
},
"repo": {
- "description": "Optional repository name. If provided with owner, only notifications for this repository are listed.",
+ "description": "Optional repository name. If provided with owner, only issues for this repository are listed.",
"type": "string"
},
"sort": {
diff --git a/pkg/github/__toolsnaps__/search_pull_requests.snap b/pkg/github/__toolsnaps__/search_pull_requests.snap
index 6a8d8e0e6..811aa1322 100644
--- a/pkg/github/__toolsnaps__/search_pull_requests.snap
+++ b/pkg/github/__toolsnaps__/search_pull_requests.snap
@@ -15,7 +15,7 @@
"type": "string"
},
"owner": {
- "description": "Optional repository owner. If provided with repo, only notifications for this repository are listed.",
+ "description": "Optional repository owner. If provided with repo, only pull requests for this repository are listed.",
"type": "string"
},
"page": {
@@ -34,7 +34,7 @@
"type": "string"
},
"repo": {
- "description": "Optional repository name. If provided with owner, only notifications for this repository are listed.",
+ "description": "Optional repository name. If provided with owner, only pull requests for this repository are listed.",
"type": "string"
},
"sort": {
diff --git a/pkg/github/__toolsnaps__/search_repositories.snap b/pkg/github/__toolsnaps__/search_repositories.snap
index b6b6d1d44..f350c8e2b 100644
--- a/pkg/github/__toolsnaps__/search_repositories.snap
+++ b/pkg/github/__toolsnaps__/search_repositories.snap
@@ -3,9 +3,14 @@
"title": "Search repositories",
"readOnlyHint": true
},
- "description": "Search for GitHub repositories",
+ "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": {
+ "minimal_output": {
+ "default": true,
+ "description": "Return minimal repository information (default: true). When false, returns full GitHub API repository objects.",
+ "type": "boolean"
+ },
"page": {
"description": "Page number for pagination (min 1)",
"minimum": 1,
@@ -18,7 +23,7 @@
"type": "number"
},
"query": {
- "description": "Search 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 5cf9796f2..73ff7a43c 100644
--- a/pkg/github/__toolsnaps__/search_users.snap
+++ b/pkg/github/__toolsnaps__/search_users.snap
@@ -3,7 +3,7 @@
"title": "Search users",
"readOnlyHint": true
},
- "description": "Search for GitHub users exclusively",
+ "description": "Find GitHub users by username, real name, or other profile information. Useful for locating developers, contributors, or team members.",
"inputSchema": {
"properties": {
"order": {
@@ -26,11 +26,11 @@
"type": "number"
},
"query": {
- "description": "Search query using GitHub users search syntax scoped to type:user",
+ "description": "User search query. Examples: 'john smith', 'location:seattle', 'followers:\u003e100'. Search is automatically scoped to type:user.",
"type": "string"
},
"sort": {
- "description": "Sort field by category",
+ "description": "Sort users by number of followers or repositories, or when the person joined GitHub.",
"enum": [
"followers",
"repositories",
diff --git a/pkg/github/__toolsnaps__/update_issue.snap b/pkg/github/__toolsnaps__/update_issue.snap
index 4bcae7ba7..d95579159 100644
--- a/pkg/github/__toolsnaps__/update_issue.snap
+++ b/pkg/github/__toolsnaps__/update_issue.snap
@@ -51,6 +51,10 @@
"title": {
"description": "New title",
"type": "string"
+ },
+ "type": {
+ "description": "New issue type",
+ "type": "string"
}
},
"required": [
diff --git a/pkg/github/__toolsnaps__/update_pull_request.snap b/pkg/github/__toolsnaps__/update_pull_request.snap
index 765983afd..25170ed5f 100644
--- a/pkg/github/__toolsnaps__/update_pull_request.snap
+++ b/pkg/github/__toolsnaps__/update_pull_request.snap
@@ -14,6 +14,10 @@
"description": "New description",
"type": "string"
},
+ "draft": {
+ "description": "Mark pull request as draft (true) or ready for review (false)",
+ "type": "boolean"
+ },
"maintainer_can_modify": {
"description": "Allow maintainer edits",
"type": "boolean"
@@ -30,6 +34,13 @@
"description": "Repository name",
"type": "string"
},
+ "reviewers": {
+ "description": "GitHub usernames to request reviews from",
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
"state": {
"description": "New state",
"enum": [
diff --git a/pkg/github/actions.go b/pkg/github/actions.go
index 3c441d5aa..ace9d7288 100644
--- a/pkg/github/actions.go
+++ b/pkg/github/actions.go
@@ -4,14 +4,15 @@ import (
"context"
"encoding/json"
"fmt"
- "io"
"net/http"
"strconv"
"strings"
+ "github.com/github/github-mcp-server/internal/profiler"
+ buffer "github.com/github/github-mcp-server/pkg/buffer"
ghErrors "github.com/github/github-mcp-server/pkg/errors"
"github.com/github/github-mcp-server/pkg/translations"
- "github.com/google/go-github/v73/github"
+ "github.com/google/go-github/v74/github"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
)
@@ -62,8 +63,8 @@ func ListWorkflows(getClient GetClientFn, t translations.TranslationHelperFunc)
// Set up list options
opts := &github.ListOptions{
- PerPage: pagination.perPage,
- Page: pagination.page,
+ PerPage: pagination.PerPage,
+ Page: pagination.Page,
}
workflows, resp, err := client.Actions.ListWorkflows(ctx, owner, repo, opts)
@@ -200,8 +201,8 @@ func ListWorkflowRuns(getClient GetClientFn, t translations.TranslationHelperFun
Event: event,
Status: status,
ListOptions: github.ListOptions{
- PerPage: pagination.perPage,
- Page: pagination.page,
+ PerPage: pagination.PerPage,
+ Page: pagination.Page,
},
}
@@ -503,8 +504,8 @@ func ListWorkflowJobs(getClient GetClientFn, t translations.TranslationHelperFun
opts := &github.ListWorkflowJobsOptions{
Filter: filter,
ListOptions: github.ListOptions{
- PerPage: pagination.perPage,
- Page: pagination.page,
+ PerPage: pagination.PerPage,
+ Page: pagination.Page,
},
}
@@ -530,7 +531,7 @@ func ListWorkflowJobs(getClient GetClientFn, t translations.TranslationHelperFun
}
// GetJobLogs creates a tool to download logs for a specific workflow job or efficiently get all failed job logs for a workflow run
-func GetJobLogs(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
+func GetJobLogs(getClient GetClientFn, t translations.TranslationHelperFunc, contentWindowSize int) (tool mcp.Tool, handler server.ToolHandlerFunc) {
return mcp.NewTool("get_job_logs",
mcp.WithDescription(t("TOOL_GET_JOB_LOGS_DESCRIPTION", "Download logs for a specific workflow job or efficiently get all failed job logs for a workflow run")),
mcp.WithToolAnnotation(mcp.ToolAnnotation{
@@ -613,10 +614,10 @@ func GetJobLogs(getClient GetClientFn, t translations.TranslationHelperFunc) (to
if failedOnly && runID > 0 {
// Handle failed-only mode: get logs for all failed jobs in the workflow run
- return handleFailedJobLogs(ctx, client, owner, repo, int64(runID), returnContent, tailLines)
+ return handleFailedJobLogs(ctx, client, owner, repo, int64(runID), returnContent, tailLines, contentWindowSize)
} else if jobID > 0 {
// Handle single job mode
- return handleSingleJobLogs(ctx, client, owner, repo, int64(jobID), returnContent, tailLines)
+ return handleSingleJobLogs(ctx, client, owner, repo, int64(jobID), returnContent, tailLines, contentWindowSize)
}
return mcp.NewToolResultError("Either job_id must be provided for single job logs, or run_id with failed_only=true for failed job logs"), nil
@@ -624,7 +625,7 @@ func GetJobLogs(getClient GetClientFn, t translations.TranslationHelperFunc) (to
}
// handleFailedJobLogs gets logs for all failed jobs in a workflow run
-func handleFailedJobLogs(ctx context.Context, client *github.Client, owner, repo string, runID int64, returnContent bool, tailLines int) (*mcp.CallToolResult, error) {
+func handleFailedJobLogs(ctx context.Context, client *github.Client, owner, repo string, runID int64, returnContent bool, tailLines int, contentWindowSize int) (*mcp.CallToolResult, error) {
// First, get all jobs for the workflow run
jobs, resp, err := client.Actions.ListWorkflowJobs(ctx, owner, repo, runID, &github.ListWorkflowJobsOptions{
Filter: "latest",
@@ -656,7 +657,7 @@ func handleFailedJobLogs(ctx context.Context, client *github.Client, owner, repo
// Collect logs for all failed jobs
var logResults []map[string]any
for _, job := range failedJobs {
- jobResult, resp, err := getJobLogData(ctx, client, owner, repo, job.GetID(), job.GetName(), returnContent, tailLines)
+ jobResult, resp, err := getJobLogData(ctx, client, owner, repo, job.GetID(), job.GetName(), returnContent, tailLines, contentWindowSize)
if err != nil {
// Continue with other jobs even if one fails
jobResult = map[string]any{
@@ -689,8 +690,8 @@ func handleFailedJobLogs(ctx context.Context, client *github.Client, owner, repo
}
// handleSingleJobLogs gets logs for a single job
-func handleSingleJobLogs(ctx context.Context, client *github.Client, owner, repo string, jobID int64, returnContent bool, tailLines int) (*mcp.CallToolResult, error) {
- jobResult, resp, err := getJobLogData(ctx, client, owner, repo, jobID, "", returnContent, tailLines)
+func handleSingleJobLogs(ctx context.Context, client *github.Client, owner, repo string, jobID int64, returnContent bool, tailLines int, contentWindowSize int) (*mcp.CallToolResult, error) {
+ jobResult, resp, err := getJobLogData(ctx, client, owner, repo, jobID, "", returnContent, tailLines, contentWindowSize)
if err != nil {
return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to get job logs", resp, err), nil
}
@@ -704,7 +705,7 @@ func handleSingleJobLogs(ctx context.Context, client *github.Client, owner, repo
}
// getJobLogData retrieves log data for a single job, either as URL or content
-func getJobLogData(ctx context.Context, client *github.Client, owner, repo string, jobID int64, jobName string, returnContent bool, tailLines int) (map[string]any, *github.Response, error) {
+func getJobLogData(ctx context.Context, client *github.Client, owner, repo string, jobID int64, jobName string, returnContent bool, tailLines int, contentWindowSize int) (map[string]any, *github.Response, error) {
// Get the download URL for the job logs
url, resp, err := client.Actions.GetWorkflowJobLogs(ctx, owner, repo, jobID, 1)
if err != nil {
@@ -721,7 +722,7 @@ func getJobLogData(ctx context.Context, client *github.Client, owner, repo strin
if returnContent {
// Download and return the actual log content
- content, originalLength, httpResp, err := downloadLogContent(url.String(), tailLines) //nolint:bodyclose // Response body is closed in downloadLogContent, but we need to return httpResp
+ content, originalLength, httpResp, err := downloadLogContent(ctx, url.String(), tailLines, contentWindowSize) //nolint:bodyclose // Response body is closed in downloadLogContent, but we need to return httpResp
if err != nil {
// To keep the return value consistent wrap the response as a GitHub Response
ghRes := &github.Response{
@@ -742,9 +743,11 @@ func getJobLogData(ctx context.Context, client *github.Client, owner, repo strin
return result, resp, nil
}
-// downloadLogContent downloads the actual log content from a GitHub logs URL
-func downloadLogContent(logURL string, tailLines int) (string, int, *http.Response, error) {
- httpResp, err := http.Get(logURL) //nolint:gosec // URLs are provided by GitHub API and are safe
+func downloadLogContent(ctx context.Context, logURL string, tailLines int, maxLines int) (string, int, *http.Response, error) {
+ prof := profiler.New(nil, profiler.IsProfilingEnabled())
+ finish := prof.Start(ctx, "log_buffer_processing")
+
+ httpResp, err := http.Get(logURL) //nolint:gosec
if err != nil {
return "", 0, httpResp, fmt.Errorf("failed to download logs: %w", err)
}
@@ -754,36 +757,25 @@ func downloadLogContent(logURL string, tailLines int) (string, int, *http.Respon
return "", 0, httpResp, fmt.Errorf("failed to download logs: HTTP %d", httpResp.StatusCode)
}
- content, err := io.ReadAll(httpResp.Body)
+ bufferSize := tailLines
+ if bufferSize > maxLines {
+ bufferSize = maxLines
+ }
+
+ processedInput, totalLines, httpResp, err := buffer.ProcessResponseAsRingBufferToEnd(httpResp, bufferSize)
if err != nil {
- return "", 0, httpResp, fmt.Errorf("failed to read log content: %w", err)
+ return "", 0, httpResp, fmt.Errorf("failed to process log content: %w", err)
}
- // Clean up and format the log content for better readability
- logContent := strings.TrimSpace(string(content))
+ lines := strings.Split(processedInput, "\n")
+ if len(lines) > tailLines {
+ lines = lines[len(lines)-tailLines:]
+ }
+ finalResult := strings.Join(lines, "\n")
- trimmedContent, lineCount := trimContent(logContent, tailLines)
- return trimmedContent, lineCount, httpResp, nil
-}
+ _ = finish(len(lines), int64(len(finalResult)))
-// trimContent trims the content to a maximum length and returns the trimmed content and an original length
-func trimContent(content string, tailLines int) (string, int) {
- // Truncate to tail_lines if specified
- lineCount := 0
- if tailLines > 0 {
-
- // Count backwards to find the nth newline from the end and a total number of lines
- for i := len(content) - 1; i >= 0 && lineCount < tailLines; i-- {
- if content[i] == '\n' {
- lineCount++
- // If we have reached the tailLines, trim the content
- if lineCount == tailLines {
- content = content[i+1:]
- }
- }
- }
- }
- return content, lineCount
+ return finalResult, totalLines, httpResp, nil
}
// RerunWorkflowRun creates a tool to re-run an entire workflow run
@@ -955,7 +947,9 @@ func CancelWorkflowRun(getClient GetClientFn, t translations.TranslationHelperFu
resp, err := client.Actions.CancelWorkflowRunByID(ctx, owner, repo, runID)
if err != nil {
- return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to cancel workflow run", resp, err), nil
+ if _, ok := err.(*github.AcceptedError); !ok {
+ return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to cancel workflow run", resp, err), nil
+ }
}
defer func() { _ = resp.Body.Close() }()
@@ -1025,8 +1019,8 @@ func ListWorkflowRunArtifacts(getClient GetClientFn, t translations.TranslationH
// Set up list options
opts := &github.ListOptions{
- PerPage: pagination.perPage,
- Page: pagination.page,
+ PerPage: pagination.PerPage,
+ Page: pagination.Page,
}
artifacts, resp, err := client.Actions.ListWorkflowRunArtifacts(ctx, owner, repo, runID, opts)
diff --git a/pkg/github/actions_test.go b/pkg/github/actions_test.go
index cb33cbe6b..555ec04cb 100644
--- a/pkg/github/actions_test.go
+++ b/pkg/github/actions_test.go
@@ -3,12 +3,19 @@ package github
import (
"context"
"encoding/json"
+ "io"
"net/http"
"net/http/httptest"
+ "os"
+ "runtime"
+ "runtime/debug"
+ "strings"
"testing"
+ "github.com/github/github-mcp-server/internal/profiler"
+ buffer "github.com/github/github-mcp-server/pkg/buffer"
"github.com/github/github-mcp-server/pkg/translations"
- "github.com/google/go-github/v73/github"
+ "github.com/google/go-github/v74/github"
"github.com/migueleliasweb/go-github-mock/src/mock"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -323,12 +330,14 @@ func Test_CancelWorkflowRun(t *testing.T) {
{
name: "successful workflow run cancellation",
mockedClient: mock.NewMockedHTTPClient(
- mock.WithRequestMatch(
+ mock.WithRequestMatchHandler(
mock.EndpointPattern{
Pattern: "/repos/owner/repo/actions/runs/12345/cancel",
Method: "POST",
},
- "", // Empty response body for 202 Accepted
+ http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(http.StatusAccepted)
+ }),
),
),
requestArgs: map[string]any{
@@ -338,6 +347,27 @@ func Test_CancelWorkflowRun(t *testing.T) {
},
expectError: false,
},
+ {
+ name: "conflict when cancelling a workflow run",
+ mockedClient: mock.NewMockedHTTPClient(
+ mock.WithRequestMatchHandler(
+ mock.EndpointPattern{
+ Pattern: "/repos/owner/repo/actions/runs/12345/cancel",
+ Method: "POST",
+ },
+ http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(http.StatusConflict)
+ }),
+ ),
+ ),
+ requestArgs: map[string]any{
+ "owner": "owner",
+ "repo": "repo",
+ "run_id": float64(12345),
+ },
+ expectError: true,
+ expectedErrMsg: "failed to cancel workflow run",
+ },
{
name: "missing required parameter run_id",
mockedClient: mock.NewMockedHTTPClient(),
@@ -369,7 +399,7 @@ func Test_CancelWorkflowRun(t *testing.T) {
textContent := getTextResult(t, result)
if tc.expectedErrMsg != "" {
- assert.Equal(t, tc.expectedErrMsg, textContent.Text)
+ assert.Contains(t, textContent.Text, tc.expectedErrMsg)
return
}
@@ -784,7 +814,7 @@ func Test_GetWorkflowRunUsage(t *testing.T) {
func Test_GetJobLogs(t *testing.T) {
// Verify tool definition once
mockClient := github.NewClient(nil)
- tool, _ := GetJobLogs(stubGetClientFn(mockClient), translations.NullTranslationHelper)
+ tool, _ := GetJobLogs(stubGetClientFn(mockClient), translations.NullTranslationHelper, 5000)
assert.Equal(t, "get_job_logs", tool.Name)
assert.NotEmpty(t, tool.Description)
@@ -1013,7 +1043,7 @@ func Test_GetJobLogs(t *testing.T) {
t.Run(tc.name, func(t *testing.T) {
// Setup client with mock
client := github.NewClient(tc.mockedClient)
- _, handler := GetJobLogs(stubGetClientFn(client), translations.NullTranslationHelper)
+ _, handler := GetJobLogs(stubGetClientFn(client), translations.NullTranslationHelper, 5000)
// Create call request
request := createMCPRequest(tc.requestArgs)
@@ -1072,7 +1102,7 @@ func Test_GetJobLogs_WithContentReturn(t *testing.T) {
)
client := github.NewClient(mockedClient)
- _, handler := GetJobLogs(stubGetClientFn(client), translations.NullTranslationHelper)
+ _, handler := GetJobLogs(stubGetClientFn(client), translations.NullTranslationHelper, 5000)
request := createMCPRequest(map[string]any{
"owner": "owner",
@@ -1119,7 +1149,7 @@ func Test_GetJobLogs_WithContentReturnAndTailLines(t *testing.T) {
)
client := github.NewClient(mockedClient)
- _, handler := GetJobLogs(stubGetClientFn(client), translations.NullTranslationHelper)
+ _, handler := GetJobLogs(stubGetClientFn(client), translations.NullTranslationHelper, 5000)
request := createMCPRequest(map[string]any{
"owner": "owner",
@@ -1139,8 +1169,153 @@ func Test_GetJobLogs_WithContentReturnAndTailLines(t *testing.T) {
require.NoError(t, err)
assert.Equal(t, float64(123), response["job_id"])
- assert.Equal(t, float64(1), response["original_length"])
+ assert.Equal(t, float64(3), response["original_length"])
assert.Equal(t, expectedLogContent, response["logs_content"])
assert.Equal(t, "Job logs content retrieved successfully", response["message"])
assert.NotContains(t, response, "logs_url") // Should not have URL when returning content
}
+
+func Test_GetJobLogs_WithContentReturnAndLargeTailLines(t *testing.T) {
+ logContent := "Line 1\nLine 2\nLine 3"
+ expectedLogContent := "Line 1\nLine 2\nLine 3"
+
+ testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write([]byte(logContent))
+ }))
+ defer testServer.Close()
+
+ mockedClient := mock.NewMockedHTTPClient(
+ mock.WithRequestMatchHandler(
+ mock.GetReposActionsJobsLogsByOwnerByRepoByJobId,
+ http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.Header().Set("Location", testServer.URL)
+ w.WriteHeader(http.StatusFound)
+ }),
+ ),
+ )
+
+ client := github.NewClient(mockedClient)
+ _, handler := GetJobLogs(stubGetClientFn(client), translations.NullTranslationHelper, 5000)
+
+ request := createMCPRequest(map[string]any{
+ "owner": "owner",
+ "repo": "repo",
+ "job_id": float64(123),
+ "return_content": true,
+ "tail_lines": float64(100),
+ })
+
+ result, err := handler(context.Background(), request)
+ require.NoError(t, err)
+ require.False(t, result.IsError)
+
+ textContent := getTextResult(t, result)
+ var response map[string]any
+ err = json.Unmarshal([]byte(textContent.Text), &response)
+ require.NoError(t, err)
+
+ assert.Equal(t, float64(123), response["job_id"])
+ assert.Equal(t, float64(3), response["original_length"])
+ assert.Equal(t, expectedLogContent, response["logs_content"])
+ assert.Equal(t, "Job logs content retrieved successfully", response["message"])
+ assert.NotContains(t, response, "logs_url")
+}
+
+func Test_MemoryUsage_SlidingWindow_vs_NoWindow(t *testing.T) {
+ if testing.Short() {
+ t.Skip("Skipping memory profiling test in short mode")
+ }
+
+ const logLines = 100000
+ const bufferSize = 5000
+ largeLogContent := strings.Repeat("log line with some content\n", logLines-1) + "final log line"
+
+ testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write([]byte(largeLogContent))
+ }))
+ defer testServer.Close()
+
+ os.Setenv("GITHUB_MCP_PROFILING_ENABLED", "true")
+ defer os.Unsetenv("GITHUB_MCP_PROFILING_ENABLED")
+
+ profiler.InitFromEnv(nil)
+ ctx := context.Background()
+
+ debug.SetGCPercent(-1)
+ defer debug.SetGCPercent(100)
+
+ for i := 0; i < 3; i++ {
+ runtime.GC()
+ }
+
+ var baselineStats runtime.MemStats
+ runtime.ReadMemStats(&baselineStats)
+
+ profile1, err1 := profiler.ProfileFuncWithMetrics(ctx, "sliding_window", func() (int, int64, error) {
+ resp1, err := http.Get(testServer.URL)
+ if err != nil {
+ return 0, 0, err
+ }
+ defer resp1.Body.Close() //nolint:bodyclose
+ content, totalLines, _, err := buffer.ProcessResponseAsRingBufferToEnd(resp1, bufferSize) //nolint:bodyclose
+ return totalLines, int64(len(content)), err
+ })
+ require.NoError(t, err1)
+
+ for i := 0; i < 3; i++ {
+ runtime.GC()
+ }
+
+ profile2, err2 := profiler.ProfileFuncWithMetrics(ctx, "no_window", func() (int, int64, error) {
+ resp2, err := http.Get(testServer.URL)
+ if err != nil {
+ return 0, 0, err
+ }
+ defer resp2.Body.Close() //nolint:bodyclose
+
+ allContent, err := io.ReadAll(resp2.Body)
+ if err != nil {
+ return 0, 0, err
+ }
+
+ allLines := strings.Split(string(allContent), "\n")
+ var nonEmptyLines []string
+ for _, line := range allLines {
+ if line != "" {
+ nonEmptyLines = append(nonEmptyLines, line)
+ }
+ }
+ totalLines := len(nonEmptyLines)
+
+ var resultLines []string
+ if totalLines > bufferSize {
+ resultLines = nonEmptyLines[totalLines-bufferSize:]
+ } else {
+ resultLines = nonEmptyLines
+ }
+
+ result := strings.Join(resultLines, "\n")
+ return totalLines, int64(len(result)), nil
+ })
+ require.NoError(t, err2)
+
+ assert.Greater(t, profile2.MemoryDelta, profile1.MemoryDelta,
+ "Sliding window should use less memory than reading all into memory")
+
+ assert.Equal(t, profile1.LinesCount, profile2.LinesCount,
+ "Both approaches should count the same number of input lines")
+ assert.InDelta(t, profile1.BytesCount, profile2.BytesCount, 100,
+ "Both approaches should produce similar output sizes (within 100 bytes)")
+
+ memoryReduction := float64(profile2.MemoryDelta-profile1.MemoryDelta) / float64(profile2.MemoryDelta) * 100
+ t.Logf("Memory reduction: %.1f%% (%.2f MB vs %.2f MB)",
+ memoryReduction,
+ float64(profile2.MemoryDelta)/1024/1024,
+ float64(profile1.MemoryDelta)/1024/1024)
+
+ t.Logf("Baseline: %d bytes", baselineStats.Alloc)
+ t.Logf("Sliding window: %s", profile1.String())
+ t.Logf("No window: %s", profile2.String())
+}
diff --git a/pkg/github/code_scanning.go b/pkg/github/code_scanning.go
index 6b15c0c45..47eaa4be0 100644
--- a/pkg/github/code_scanning.go
+++ b/pkg/github/code_scanning.go
@@ -9,7 +9,7 @@ import (
ghErrors "github.com/github/github-mcp-server/pkg/errors"
"github.com/github/github-mcp-server/pkg/translations"
- "github.com/google/go-github/v73/github"
+ "github.com/google/go-github/v74/github"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
)
diff --git a/pkg/github/code_scanning_test.go b/pkg/github/code_scanning_test.go
index 66f6fd6cc..5d4cc732d 100644
--- a/pkg/github/code_scanning_test.go
+++ b/pkg/github/code_scanning_test.go
@@ -8,7 +8,7 @@ import (
"github.com/github/github-mcp-server/internal/toolsnaps"
"github.com/github/github-mcp-server/pkg/translations"
- "github.com/google/go-github/v73/github"
+ "github.com/google/go-github/v74/github"
"github.com/migueleliasweb/go-github-mock/src/mock"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
diff --git a/pkg/github/context_tools.go b/pkg/github/context_tools.go
index 9817fea7b..06642aa15 100644
--- a/pkg/github/context_tools.go
+++ b/pkg/github/context_tools.go
@@ -8,6 +8,7 @@ import (
"github.com/github/github-mcp-server/pkg/translations"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
+ "github.com/shurcooL/githubv4"
)
// UserDetails contains additional fields about a GitHub user not already
@@ -90,3 +91,161 @@ func GetMe(getClient GetClientFn, t translations.TranslationHelperFunc) (mcp.Too
return tool, handler
}
+
+type TeamInfo struct {
+ Name string `json:"name"`
+ Slug string `json:"slug"`
+ Description string `json:"description"`
+}
+
+type OrganizationTeams struct {
+ Org string `json:"org"`
+ Teams []TeamInfo `json:"teams"`
+}
+
+func GetTeams(getClient GetClientFn, getGQLClient GetGQLClientFn, t translations.TranslationHelperFunc) (mcp.Tool, server.ToolHandlerFunc) {
+ return mcp.NewTool("get_teams",
+ mcp.WithDescription(t("TOOL_GET_TEAMS_DESCRIPTION", "Get details of the teams the user is a member of. Limited to organizations accessible with current credentials")),
+ mcp.WithString("user",
+ mcp.Description(t("TOOL_GET_TEAMS_USER_DESCRIPTION", "Username to get teams for. If not provided, uses the authenticated user.")),
+ ),
+ mcp.WithToolAnnotation(mcp.ToolAnnotation{
+ Title: t("TOOL_GET_TEAMS_TITLE", "Get teams"),
+ ReadOnlyHint: ToBoolPtr(true),
+ }),
+ ),
+ func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
+ user, err := OptionalParam[string](request, "user")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+
+ var username string
+ if user != "" {
+ username = user
+ } else {
+ client, err := getClient(ctx)
+ if err != nil {
+ return mcp.NewToolResultErrorFromErr("failed to get GitHub client", err), nil
+ }
+
+ userResp, res, err := client.Users.Get(ctx, "")
+ if err != nil {
+ return ghErrors.NewGitHubAPIErrorResponse(ctx,
+ "failed to get user",
+ res,
+ err,
+ ), nil
+ }
+ username = userResp.GetLogin()
+ }
+
+ gqlClient, err := getGQLClient(ctx)
+ if err != nil {
+ return mcp.NewToolResultErrorFromErr("failed to get GitHub GQL client", err), nil
+ }
+
+ var q struct {
+ User struct {
+ Organizations struct {
+ Nodes []struct {
+ Login githubv4.String
+ Teams struct {
+ Nodes []struct {
+ Name githubv4.String
+ Slug githubv4.String
+ Description githubv4.String
+ }
+ } `graphql:"teams(first: 100, userLogins: [$login])"`
+ }
+ } `graphql:"organizations(first: 100)"`
+ } `graphql:"user(login: $login)"`
+ }
+ vars := map[string]interface{}{
+ "login": githubv4.String(username),
+ }
+ if err := gqlClient.Query(ctx, &q, vars); err != nil {
+ return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "Failed to find teams", err), nil
+ }
+
+ var organizations []OrganizationTeams
+ for _, org := range q.User.Organizations.Nodes {
+ orgTeams := OrganizationTeams{
+ Org: string(org.Login),
+ Teams: make([]TeamInfo, 0, len(org.Teams.Nodes)),
+ }
+
+ for _, team := range org.Teams.Nodes {
+ orgTeams.Teams = append(orgTeams.Teams, TeamInfo{
+ Name: string(team.Name),
+ Slug: string(team.Slug),
+ Description: string(team.Description),
+ })
+ }
+
+ organizations = append(organizations, orgTeams)
+ }
+
+ return MarshalledTextResult(organizations), nil
+ }
+}
+
+func GetTeamMembers(getGQLClient GetGQLClientFn, t translations.TranslationHelperFunc) (mcp.Tool, server.ToolHandlerFunc) {
+ return mcp.NewTool("get_team_members",
+ mcp.WithDescription(t("TOOL_GET_TEAM_MEMBERS_DESCRIPTION", "Get member usernames of a specific team in an organization. Limited to organizations accessible with current credentials")),
+ mcp.WithString("org",
+ mcp.Description(t("TOOL_GET_TEAM_MEMBERS_ORG_DESCRIPTION", "Organization login (owner) that contains the team.")),
+ mcp.Required(),
+ ),
+ mcp.WithString("team_slug",
+ mcp.Description(t("TOOL_GET_TEAM_MEMBERS_TEAM_SLUG_DESCRIPTION", "Team slug")),
+ mcp.Required(),
+ ),
+ mcp.WithToolAnnotation(mcp.ToolAnnotation{
+ Title: t("TOOL_GET_TEAM_MEMBERS_TITLE", "Get team members"),
+ ReadOnlyHint: ToBoolPtr(true),
+ }),
+ ),
+ func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
+ org, err := RequiredParam[string](request, "org")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+
+ teamSlug, err := RequiredParam[string](request, "team_slug")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+
+ gqlClient, err := getGQLClient(ctx)
+ if err != nil {
+ return mcp.NewToolResultErrorFromErr("failed to get GitHub GQL client", err), nil
+ }
+
+ var q struct {
+ Organization struct {
+ Team struct {
+ Members struct {
+ Nodes []struct {
+ Login githubv4.String
+ }
+ } `graphql:"members(first: 100)"`
+ } `graphql:"team(slug: $teamSlug)"`
+ } `graphql:"organization(login: $org)"`
+ }
+ vars := map[string]interface{}{
+ "org": githubv4.String(org),
+ "teamSlug": githubv4.String(teamSlug),
+ }
+ if err := gqlClient.Query(ctx, &q, vars); err != nil {
+ return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "Failed to get team members", err), nil
+ }
+
+ var members []string
+ for _, member := range q.Organization.Team.Members.Nodes {
+ members = append(members, string(member.Login))
+ }
+
+ return MarshalledTextResult(members), nil
+ }
+}
diff --git a/pkg/github/context_tools_test.go b/pkg/github/context_tools_test.go
index 56f61e936..641707a47 100644
--- a/pkg/github/context_tools_test.go
+++ b/pkg/github/context_tools_test.go
@@ -3,13 +3,16 @@ package github
import (
"context"
"encoding/json"
+ "fmt"
"testing"
"time"
+ "github.com/github/github-mcp-server/internal/githubv4mock"
"github.com/github/github-mcp-server/internal/toolsnaps"
"github.com/github/github-mcp-server/pkg/translations"
- "github.com/google/go-github/v73/github"
+ "github.com/google/go-github/v74/github"
"github.com/migueleliasweb/go-github-mock/src/mock"
+ "github.com/shurcooL/githubv4"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -139,3 +142,358 @@ func Test_GetMe(t *testing.T) {
})
}
}
+
+func Test_GetTeams(t *testing.T) {
+ t.Parallel()
+
+ tool, _ := GetTeams(nil, nil, translations.NullTranslationHelper)
+ require.NoError(t, toolsnaps.Test(tool.Name, tool))
+
+ assert.Equal(t, "get_teams", tool.Name)
+ assert.True(t, *tool.Annotations.ReadOnlyHint, "get_teams tool should be read-only")
+
+ mockUser := &github.User{
+ Login: github.Ptr("testuser"),
+ Name: github.Ptr("Test User"),
+ Email: github.Ptr("test@example.com"),
+ Bio: github.Ptr("GitHub user for testing"),
+ Company: github.Ptr("Test Company"),
+ Location: github.Ptr("Test Location"),
+ HTMLURL: github.Ptr("https://github.com/testuser"),
+ CreatedAt: &github.Timestamp{Time: time.Now().Add(-365 * 24 * time.Hour)},
+ Type: github.Ptr("User"),
+ Hireable: github.Ptr(true),
+ TwitterUsername: github.Ptr("testuser_twitter"),
+ Plan: &github.Plan{
+ Name: github.Ptr("pro"),
+ },
+ }
+
+ mockTeamsResponse := githubv4mock.DataResponse(map[string]any{
+ "user": map[string]any{
+ "organizations": map[string]any{
+ "nodes": []map[string]any{
+ {
+ "login": "testorg1",
+ "teams": map[string]any{
+ "nodes": []map[string]any{
+ {
+ "name": "team1",
+ "slug": "team1",
+ "description": "Team 1",
+ },
+ {
+ "name": "team2",
+ "slug": "team2",
+ "description": "Team 2",
+ },
+ },
+ },
+ },
+ {
+ "login": "testorg2",
+ "teams": map[string]any{
+ "nodes": []map[string]any{
+ {
+ "name": "team3",
+ "slug": "team3",
+ "description": "Team 3",
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ })
+
+ mockNoTeamsResponse := githubv4mock.DataResponse(map[string]any{
+ "user": map[string]any{
+ "organizations": map[string]any{
+ "nodes": []map[string]any{},
+ },
+ },
+ })
+
+ tests := []struct {
+ name string
+ stubbedGetClientFn GetClientFn
+ stubbedGetGQLClientFn GetGQLClientFn
+ requestArgs map[string]any
+ expectToolError bool
+ expectedToolErrMsg string
+ expectedTeamsCount int
+ }{
+ {
+ name: "successful get teams",
+ stubbedGetClientFn: stubGetClientFromHTTPFn(
+ mock.NewMockedHTTPClient(
+ mock.WithRequestMatch(
+ mock.GetUser,
+ mockUser,
+ ),
+ ),
+ ),
+ stubbedGetGQLClientFn: func(_ context.Context) (*githubv4.Client, error) {
+ queryStr := "query($login:String!){user(login: $login){organizations(first: 100){nodes{login,teams(first: 100, userLogins: [$login]){nodes{name,slug,description}}}}}}"
+ vars := map[string]interface{}{
+ "login": "testuser",
+ }
+ matcher := githubv4mock.NewQueryMatcher(queryStr, vars, mockTeamsResponse)
+ httpClient := githubv4mock.NewMockedHTTPClient(matcher)
+ return githubv4.NewClient(httpClient), nil
+ },
+ requestArgs: map[string]any{},
+ expectToolError: false,
+ expectedTeamsCount: 2,
+ },
+ {
+ name: "successful get teams for specific user",
+ stubbedGetClientFn: nil,
+ stubbedGetGQLClientFn: func(_ context.Context) (*githubv4.Client, error) {
+ queryStr := "query($login:String!){user(login: $login){organizations(first: 100){nodes{login,teams(first: 100, userLogins: [$login]){nodes{name,slug,description}}}}}}"
+ vars := map[string]interface{}{
+ "login": "specificuser",
+ }
+ matcher := githubv4mock.NewQueryMatcher(queryStr, vars, mockTeamsResponse)
+ httpClient := githubv4mock.NewMockedHTTPClient(matcher)
+ return githubv4.NewClient(httpClient), nil
+ },
+ requestArgs: map[string]any{
+ "user": "specificuser",
+ },
+ expectToolError: false,
+ expectedTeamsCount: 2,
+ },
+ {
+ name: "no teams found",
+ stubbedGetClientFn: stubGetClientFromHTTPFn(
+ mock.NewMockedHTTPClient(
+ mock.WithRequestMatch(
+ mock.GetUser,
+ mockUser,
+ ),
+ ),
+ ),
+ stubbedGetGQLClientFn: func(_ context.Context) (*githubv4.Client, error) {
+ queryStr := "query($login:String!){user(login: $login){organizations(first: 100){nodes{login,teams(first: 100, userLogins: [$login]){nodes{name,slug,description}}}}}}"
+ vars := map[string]interface{}{
+ "login": "testuser",
+ }
+ matcher := githubv4mock.NewQueryMatcher(queryStr, vars, mockNoTeamsResponse)
+ httpClient := githubv4mock.NewMockedHTTPClient(matcher)
+ return githubv4.NewClient(httpClient), nil
+ },
+ requestArgs: map[string]any{},
+ expectToolError: false,
+ expectedTeamsCount: 0,
+ },
+ {
+ name: "getting client fails",
+ stubbedGetClientFn: stubGetClientFnErr("expected test error"),
+ stubbedGetGQLClientFn: nil,
+ requestArgs: map[string]any{},
+ expectToolError: true,
+ expectedToolErrMsg: "failed to get GitHub client: expected test error",
+ },
+ {
+ name: "get user fails",
+ stubbedGetClientFn: stubGetClientFromHTTPFn(
+ mock.NewMockedHTTPClient(
+ mock.WithRequestMatchHandler(
+ mock.GetUser,
+ badRequestHandler("expected test failure"),
+ ),
+ ),
+ ),
+ stubbedGetGQLClientFn: nil,
+ requestArgs: map[string]any{},
+ expectToolError: true,
+ expectedToolErrMsg: "expected test failure",
+ },
+ {
+ name: "getting GraphQL client fails",
+ stubbedGetClientFn: stubGetClientFromHTTPFn(
+ mock.NewMockedHTTPClient(
+ mock.WithRequestMatch(
+ mock.GetUser,
+ mockUser,
+ ),
+ ),
+ ),
+ stubbedGetGQLClientFn: func(_ context.Context) (*githubv4.Client, error) {
+ return nil, fmt.Errorf("GraphQL client error")
+ },
+ requestArgs: map[string]any{},
+ expectToolError: true,
+ expectedToolErrMsg: "failed to get GitHub GQL client: GraphQL client error",
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ _, handler := GetTeams(tc.stubbedGetClientFn, tc.stubbedGetGQLClientFn, translations.NullTranslationHelper)
+
+ request := createMCPRequest(tc.requestArgs)
+ result, err := handler(context.Background(), request)
+ require.NoError(t, err)
+ textContent := getTextResult(t, result)
+
+ if tc.expectToolError {
+ assert.True(t, result.IsError, "expected tool call result to be an error")
+ assert.Contains(t, textContent.Text, tc.expectedToolErrMsg)
+ return
+ }
+
+ var organizations []OrganizationTeams
+ err = json.Unmarshal([]byte(textContent.Text), &organizations)
+ require.NoError(t, err)
+
+ assert.Len(t, organizations, tc.expectedTeamsCount)
+
+ if tc.expectedTeamsCount > 0 {
+ assert.Equal(t, "testorg1", organizations[0].Org)
+ assert.Len(t, organizations[0].Teams, 2)
+ assert.Equal(t, "team1", organizations[0].Teams[0].Name)
+ assert.Equal(t, "team1", organizations[0].Teams[0].Slug)
+ assert.Equal(t, "Team 1", organizations[0].Teams[0].Description)
+
+ if tc.expectedTeamsCount > 1 {
+ assert.Equal(t, "testorg2", organizations[1].Org)
+ assert.Len(t, organizations[1].Teams, 1)
+ assert.Equal(t, "team3", organizations[1].Teams[0].Name)
+ assert.Equal(t, "team3", organizations[1].Teams[0].Slug)
+ assert.Equal(t, "Team 3", organizations[1].Teams[0].Description)
+ }
+ }
+ })
+ }
+}
+
+func Test_GetTeamMembers(t *testing.T) {
+ t.Parallel()
+
+ tool, _ := GetTeamMembers(nil, translations.NullTranslationHelper)
+ require.NoError(t, toolsnaps.Test(tool.Name, tool))
+
+ assert.Equal(t, "get_team_members", tool.Name)
+ assert.True(t, *tool.Annotations.ReadOnlyHint, "get_team_members tool should be read-only")
+
+ mockTeamMembersResponse := githubv4mock.DataResponse(map[string]any{
+ "organization": map[string]any{
+ "team": map[string]any{
+ "members": map[string]any{
+ "nodes": []map[string]any{
+ {
+ "login": "user1",
+ },
+ {
+ "login": "user2",
+ },
+ },
+ },
+ },
+ },
+ })
+
+ mockNoMembersResponse := githubv4mock.DataResponse(map[string]any{
+ "organization": map[string]any{
+ "team": map[string]any{
+ "members": map[string]any{
+ "nodes": []map[string]any{},
+ },
+ },
+ },
+ })
+
+ tests := []struct {
+ name string
+ stubbedGetGQLClientFn GetGQLClientFn
+ requestArgs map[string]any
+ expectToolError bool
+ expectedToolErrMsg string
+ expectedMembersCount int
+ }{
+ {
+ name: "successful get team members",
+ stubbedGetGQLClientFn: func(_ context.Context) (*githubv4.Client, error) {
+ queryStr := "query($org:String!$teamSlug:String!){organization(login: $org){team(slug: $teamSlug){members(first: 100){nodes{login}}}}}"
+ vars := map[string]interface{}{
+ "org": "testorg",
+ "teamSlug": "testteam",
+ }
+ matcher := githubv4mock.NewQueryMatcher(queryStr, vars, mockTeamMembersResponse)
+ httpClient := githubv4mock.NewMockedHTTPClient(matcher)
+ return githubv4.NewClient(httpClient), nil
+ },
+ requestArgs: map[string]any{
+ "org": "testorg",
+ "team_slug": "testteam",
+ },
+ expectToolError: false,
+ expectedMembersCount: 2,
+ },
+ {
+ name: "team with no members",
+ stubbedGetGQLClientFn: func(_ context.Context) (*githubv4.Client, error) {
+ queryStr := "query($org:String!$teamSlug:String!){organization(login: $org){team(slug: $teamSlug){members(first: 100){nodes{login}}}}}"
+ vars := map[string]interface{}{
+ "org": "testorg",
+ "teamSlug": "emptyteam",
+ }
+ matcher := githubv4mock.NewQueryMatcher(queryStr, vars, mockNoMembersResponse)
+ httpClient := githubv4mock.NewMockedHTTPClient(matcher)
+ return githubv4.NewClient(httpClient), nil
+ },
+ requestArgs: map[string]any{
+ "org": "testorg",
+ "team_slug": "emptyteam",
+ },
+ expectToolError: false,
+ expectedMembersCount: 0,
+ },
+ {
+ name: "getting GraphQL client fails",
+ stubbedGetGQLClientFn: func(_ context.Context) (*githubv4.Client, error) {
+ return nil, fmt.Errorf("GraphQL client error")
+ },
+ requestArgs: map[string]any{
+ "org": "testorg",
+ "team_slug": "testteam",
+ },
+ expectToolError: true,
+ expectedToolErrMsg: "failed to get GitHub GQL client: GraphQL client error",
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ _, handler := GetTeamMembers(tc.stubbedGetGQLClientFn, translations.NullTranslationHelper)
+
+ request := createMCPRequest(tc.requestArgs)
+ result, err := handler(context.Background(), request)
+ require.NoError(t, err)
+ textContent := getTextResult(t, result)
+
+ if tc.expectToolError {
+ assert.True(t, result.IsError, "expected tool call result to be an error")
+ assert.Contains(t, textContent.Text, tc.expectedToolErrMsg)
+ return
+ }
+
+ var members []string
+ err = json.Unmarshal([]byte(textContent.Text), &members)
+ require.NoError(t, err)
+
+ assert.Len(t, members, tc.expectedMembersCount)
+
+ if tc.expectedMembersCount > 0 {
+ assert.Equal(t, "user1", members[0])
+
+ if tc.expectedMembersCount > 1 {
+ assert.Equal(t, "user2", members[1])
+ }
+ }
+ })
+ }
+}
diff --git a/pkg/github/dependabot.go b/pkg/github/dependabot.go
index c2a4d5b0d..7b327cd77 100644
--- a/pkg/github/dependabot.go
+++ b/pkg/github/dependabot.go
@@ -9,7 +9,7 @@ import (
ghErrors "github.com/github/github-mcp-server/pkg/errors"
"github.com/github/github-mcp-server/pkg/translations"
- "github.com/google/go-github/v73/github"
+ "github.com/google/go-github/v74/github"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
)
diff --git a/pkg/github/dependabot_test.go b/pkg/github/dependabot_test.go
index 8a7270d7f..c3ec0408d 100644
--- a/pkg/github/dependabot_test.go
+++ b/pkg/github/dependabot_test.go
@@ -8,7 +8,7 @@ import (
"github.com/github/github-mcp-server/internal/toolsnaps"
"github.com/github/github-mcp-server/pkg/translations"
- "github.com/google/go-github/v73/github"
+ "github.com/google/go-github/v74/github"
"github.com/migueleliasweb/go-github-mock/src/mock"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
diff --git a/pkg/github/discussions.go b/pkg/github/discussions.go
index 23e2724d4..dc26063fd 100644
--- a/pkg/github/discussions.go
+++ b/pkg/github/discussions.go
@@ -7,15 +7,119 @@ import (
"github.com/github/github-mcp-server/pkg/translations"
"github.com/go-viper/mapstructure/v2"
- "github.com/google/go-github/v73/github"
+ "github.com/google/go-github/v74/github"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
"github.com/shurcooL/githubv4"
)
+const DefaultGraphQLPageSize = 30
+
+// Common interface for all discussion query types
+type DiscussionQueryResult interface {
+ GetDiscussionFragment() DiscussionFragment
+}
+
+// Implement the interface for all query types
+func (q *BasicNoOrder) GetDiscussionFragment() DiscussionFragment {
+ return q.Repository.Discussions
+}
+
+func (q *BasicWithOrder) GetDiscussionFragment() DiscussionFragment {
+ return q.Repository.Discussions
+}
+
+func (q *WithCategoryAndOrder) GetDiscussionFragment() DiscussionFragment {
+ return q.Repository.Discussions
+}
+
+func (q *WithCategoryNoOrder) GetDiscussionFragment() DiscussionFragment {
+ return q.Repository.Discussions
+}
+
+type DiscussionFragment struct {
+ Nodes []NodeFragment
+ PageInfo PageInfoFragment
+ TotalCount githubv4.Int
+}
+
+type NodeFragment struct {
+ Number githubv4.Int
+ Title githubv4.String
+ CreatedAt githubv4.DateTime
+ UpdatedAt githubv4.DateTime
+ Author struct {
+ Login githubv4.String
+ }
+ Category struct {
+ Name githubv4.String
+ } `graphql:"category"`
+ URL githubv4.String `graphql:"url"`
+}
+
+type PageInfoFragment struct {
+ HasNextPage bool
+ HasPreviousPage bool
+ StartCursor githubv4.String
+ EndCursor githubv4.String
+}
+
+type BasicNoOrder struct {
+ Repository struct {
+ Discussions DiscussionFragment `graphql:"discussions(first: $first, after: $after)"`
+ } `graphql:"repository(owner: $owner, name: $repo)"`
+}
+
+type BasicWithOrder struct {
+ Repository struct {
+ Discussions DiscussionFragment `graphql:"discussions(first: $first, after: $after, orderBy: { field: $orderByField, direction: $orderByDirection })"`
+ } `graphql:"repository(owner: $owner, name: $repo)"`
+}
+
+type WithCategoryAndOrder struct {
+ Repository struct {
+ Discussions DiscussionFragment `graphql:"discussions(first: $first, after: $after, categoryId: $categoryId, orderBy: { field: $orderByField, direction: $orderByDirection })"`
+ } `graphql:"repository(owner: $owner, name: $repo)"`
+}
+
+type WithCategoryNoOrder struct {
+ Repository struct {
+ Discussions DiscussionFragment `graphql:"discussions(first: $first, after: $after, categoryId: $categoryId)"`
+ } `graphql:"repository(owner: $owner, name: $repo)"`
+}
+
+func fragmentToDiscussion(fragment NodeFragment) *github.Discussion {
+ return &github.Discussion{
+ Number: github.Ptr(int(fragment.Number)),
+ Title: github.Ptr(string(fragment.Title)),
+ HTMLURL: github.Ptr(string(fragment.URL)),
+ CreatedAt: &github.Timestamp{Time: fragment.CreatedAt.Time},
+ UpdatedAt: &github.Timestamp{Time: fragment.UpdatedAt.Time},
+ User: &github.User{
+ Login: github.Ptr(string(fragment.Author.Login)),
+ },
+ DiscussionCategory: &github.DiscussionCategory{
+ Name: github.Ptr(string(fragment.Category.Name)),
+ },
+ }
+}
+
+func getQueryType(useOrdering bool, categoryID *githubv4.ID) any {
+ if categoryID != nil && useOrdering {
+ return &WithCategoryAndOrder{}
+ }
+ if categoryID != nil && !useOrdering {
+ return &WithCategoryNoOrder{}
+ }
+ if categoryID == nil && useOrdering {
+ return &BasicWithOrder{}
+ }
+ return &BasicNoOrder{}
+}
+
func ListDiscussions(getGQLClient GetGQLClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
return mcp.NewTool("list_discussions",
- mcp.WithDescription(t("TOOL_LIST_DISCUSSIONS_DESCRIPTION", "List discussions for a repository")),
+ mcp.WithDescription(t("TOOL_LIST_DISCUSSIONS_DESCRIPTION", "List discussions for a repository or organisation.")),
mcp.WithToolAnnotation(mcp.ToolAnnotation{
Title: t("TOOL_LIST_DISCUSSIONS_USER_TITLE", "List discussions"),
ReadOnlyHint: ToBoolPtr(true),
@@ -25,125 +129,126 @@ func ListDiscussions(getGQLClient GetGQLClientFn, t translations.TranslationHelp
mcp.Description("Repository owner"),
),
mcp.WithString("repo",
- mcp.Required(),
- mcp.Description("Repository name"),
+ mcp.Description("Repository name. If not provided, discussions will be queried at the organisation level."),
),
mcp.WithString("category",
mcp.Description("Optional filter by discussion category ID. If provided, only discussions with this category are listed."),
),
+ mcp.WithString("orderBy",
+ mcp.Description("Order discussions by field. If provided, the 'direction' also needs to be provided."),
+ mcp.Enum("CREATED_AT", "UPDATED_AT"),
+ ),
+ mcp.WithString("direction",
+ mcp.Description("Order direction."),
+ mcp.Enum("ASC", "DESC"),
+ ),
+ WithCursorPagination(),
),
func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
- // Required params
owner, err := RequiredParam[string](request, "owner")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
- repo, err := RequiredParam[string](request, "repo")
+ repo, err := OptionalParam[string](request, "repo")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
+ // when not provided, default to the .github repository
+ // this will query discussions at the organisation level
+ if repo == "" {
+ repo = ".github"
+ }
- // Optional params
category, err := OptionalParam[string](request, "category")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
+ orderBy, err := OptionalParam[string](request, "orderBy")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+
+ direction, err := OptionalParam[string](request, "direction")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+
+ // Get pagination parameters and convert to GraphQL format
+ pagination, err := OptionalCursorPaginationParams(request)
+ if err != nil {
+ return nil, err
+ }
+ paginationParams, err := pagination.ToGraphQLParams()
+ if err != nil {
+ return nil, err
+ }
+
client, err := getGQLClient(ctx)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("failed to get GitHub GQL client: %v", err)), nil
}
- // If category filter is specified, use it as the category ID for server-side filtering
var categoryID *githubv4.ID
if category != "" {
id := githubv4.ID(category)
categoryID = &id
}
- // Now execute the discussions query
- var discussions []*github.Discussion
+ vars := map[string]interface{}{
+ "owner": githubv4.String(owner),
+ "repo": githubv4.String(repo),
+ "first": githubv4.Int(*paginationParams.First),
+ }
+ if paginationParams.After != nil {
+ vars["after"] = githubv4.String(*paginationParams.After)
+ } else {
+ vars["after"] = (*githubv4.String)(nil)
+ }
+
+ // this is an extra check in case the tool description is misinterpreted, because
+ // we shouldn't use ordering unless both a 'field' and 'direction' are provided
+ useOrdering := orderBy != "" && direction != ""
+ if useOrdering {
+ vars["orderByField"] = githubv4.DiscussionOrderField(orderBy)
+ vars["orderByDirection"] = githubv4.OrderDirection(direction)
+ }
+
if categoryID != nil {
- // Query with category filter (server-side filtering)
- var query struct {
- Repository struct {
- Discussions struct {
- Nodes []struct {
- Number githubv4.Int
- Title githubv4.String
- CreatedAt githubv4.DateTime
- Category struct {
- Name githubv4.String
- } `graphql:"category"`
- URL githubv4.String `graphql:"url"`
- }
- } `graphql:"discussions(first: 100, categoryId: $categoryId)"`
- } `graphql:"repository(owner: $owner, name: $repo)"`
- }
- vars := map[string]interface{}{
- "owner": githubv4.String(owner),
- "repo": githubv4.String(repo),
- "categoryId": *categoryID,
- }
- if err := client.Query(ctx, &query, vars); err != nil {
- return mcp.NewToolResultError(err.Error()), nil
- }
+ vars["categoryId"] = *categoryID
+ }
- // Map nodes to GitHub Discussion objects
- for _, n := range query.Repository.Discussions.Nodes {
- di := &github.Discussion{
- Number: github.Ptr(int(n.Number)),
- Title: github.Ptr(string(n.Title)),
- HTMLURL: github.Ptr(string(n.URL)),
- CreatedAt: &github.Timestamp{Time: n.CreatedAt.Time},
- DiscussionCategory: &github.DiscussionCategory{
- Name: github.Ptr(string(n.Category.Name)),
- },
- }
- discussions = append(discussions, di)
- }
- } else {
- // Query without category filter
- var query struct {
- Repository struct {
- Discussions struct {
- Nodes []struct {
- Number githubv4.Int
- Title githubv4.String
- CreatedAt githubv4.DateTime
- Category struct {
- Name githubv4.String
- } `graphql:"category"`
- URL githubv4.String `graphql:"url"`
- }
- } `graphql:"discussions(first: 100)"`
- } `graphql:"repository(owner: $owner, name: $repo)"`
- }
- vars := map[string]interface{}{
- "owner": githubv4.String(owner),
- "repo": githubv4.String(repo),
- }
- if err := client.Query(ctx, &query, vars); err != nil {
- return mcp.NewToolResultError(err.Error()), nil
- }
+ discussionQuery := getQueryType(useOrdering, categoryID)
+ if err := client.Query(ctx, discussionQuery, vars); err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
- // Map nodes to GitHub Discussion objects
- for _, n := range query.Repository.Discussions.Nodes {
- di := &github.Discussion{
- Number: github.Ptr(int(n.Number)),
- Title: github.Ptr(string(n.Title)),
- HTMLURL: github.Ptr(string(n.URL)),
- CreatedAt: &github.Timestamp{Time: n.CreatedAt.Time},
- DiscussionCategory: &github.DiscussionCategory{
- Name: github.Ptr(string(n.Category.Name)),
- },
- }
- discussions = append(discussions, di)
+ // Extract and convert all discussion nodes using the common interface
+ var discussions []*github.Discussion
+ var pageInfo PageInfoFragment
+ var totalCount githubv4.Int
+ if queryResult, ok := discussionQuery.(DiscussionQueryResult); ok {
+ fragment := queryResult.GetDiscussionFragment()
+ for _, node := range fragment.Nodes {
+ discussions = append(discussions, fragmentToDiscussion(node))
}
+ pageInfo = fragment.PageInfo
+ totalCount = fragment.TotalCount
}
- // Marshal and return
- out, err := json.Marshal(discussions)
+ // Create response with pagination info
+ response := map[string]interface{}{
+ "discussions": discussions,
+ "pageInfo": map[string]interface{}{
+ "hasNextPage": pageInfo.HasNextPage,
+ "hasPreviousPage": pageInfo.HasPreviousPage,
+ "startCursor": string(pageInfo.StartCursor),
+ "endCursor": string(pageInfo.EndCursor),
+ },
+ "totalCount": totalCount,
+ }
+
+ out, err := json.Marshal(response)
if err != nil {
return nil, fmt.Errorf("failed to marshal discussions: %w", err)
}
@@ -190,6 +295,7 @@ func GetDiscussion(getGQLClient GetGQLClientFn, t translations.TranslationHelper
Repository struct {
Discussion struct {
Number githubv4.Int
+ Title githubv4.String
Body githubv4.String
CreatedAt githubv4.DateTime
URL githubv4.String `graphql:"url"`
@@ -210,6 +316,7 @@ func GetDiscussion(getGQLClient GetGQLClientFn, t translations.TranslationHelper
d := q.Repository.Discussion
discussion := &github.Discussion{
Number: github.Ptr(int(d.Number)),
+ Title: github.Ptr(string(d.Title)),
Body: github.Ptr(string(d.Body)),
HTMLURL: github.Ptr(string(d.URL)),
CreatedAt: &github.Timestamp{Time: d.CreatedAt.Time},
@@ -236,6 +343,7 @@ func GetDiscussionComments(getGQLClient GetGQLClientFn, t translations.Translati
mcp.WithString("owner", mcp.Required(), mcp.Description("Repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("Repository name")),
mcp.WithNumber("discussionNumber", mcp.Required(), mcp.Description("Discussion Number")),
+ WithCursorPagination(),
),
func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
// Decode params
@@ -248,6 +356,27 @@ func GetDiscussionComments(getGQLClient GetGQLClientFn, t translations.Translati
return mcp.NewToolResultError(err.Error()), nil
}
+ // Get pagination parameters and convert to GraphQL format
+ pagination, err := OptionalCursorPaginationParams(request)
+ if err != nil {
+ return nil, err
+ }
+
+ // Check if pagination parameters were explicitly provided
+ _, perPageProvided := request.GetArguments()["perPage"]
+ paginationExplicit := perPageProvided
+
+ paginationParams, err := pagination.ToGraphQLParams()
+ if err != nil {
+ return nil, err
+ }
+
+ // Use default of 30 if pagination was not explicitly provided
+ if !paginationExplicit {
+ defaultFirst := int32(DefaultGraphQLPageSize)
+ paginationParams.First = &defaultFirst
+ }
+
client, err := getGQLClient(ctx)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("failed to get GitHub GQL client: %v", err)), nil
@@ -260,7 +389,14 @@ func GetDiscussionComments(getGQLClient GetGQLClientFn, t translations.Translati
Nodes []struct {
Body githubv4.String
}
- } `graphql:"comments(first:100)"`
+ PageInfo struct {
+ HasNextPage githubv4.Boolean
+ HasPreviousPage githubv4.Boolean
+ StartCursor githubv4.String
+ EndCursor githubv4.String
+ }
+ TotalCount int
+ } `graphql:"comments(first: $first, after: $after)"`
} `graphql:"discussion(number: $discussionNumber)"`
} `graphql:"repository(owner: $owner, name: $repo)"`
}
@@ -268,16 +404,35 @@ func GetDiscussionComments(getGQLClient GetGQLClientFn, t translations.Translati
"owner": githubv4.String(params.Owner),
"repo": githubv4.String(params.Repo),
"discussionNumber": githubv4.Int(params.DiscussionNumber),
+ "first": githubv4.Int(*paginationParams.First),
+ }
+ if paginationParams.After != nil {
+ vars["after"] = githubv4.String(*paginationParams.After)
+ } else {
+ vars["after"] = (*githubv4.String)(nil)
}
if err := client.Query(ctx, &q, vars); err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
+
var comments []*github.IssueComment
for _, c := range q.Repository.Discussion.Comments.Nodes {
comments = append(comments, &github.IssueComment{Body: github.Ptr(string(c.Body))})
}
- out, err := json.Marshal(comments)
+ // Create response with pagination info
+ response := map[string]interface{}{
+ "comments": comments,
+ "pageInfo": map[string]interface{}{
+ "hasNextPage": q.Repository.Discussion.Comments.PageInfo.HasNextPage,
+ "hasPreviousPage": q.Repository.Discussion.Comments.PageInfo.HasPreviousPage,
+ "startCursor": string(q.Repository.Discussion.Comments.PageInfo.StartCursor),
+ "endCursor": string(q.Repository.Discussion.Comments.PageInfo.EndCursor),
+ },
+ "totalCount": q.Repository.Discussion.Comments.TotalCount,
+ }
+
+ out, err := json.Marshal(response)
if err != nil {
return nil, fmt.Errorf("failed to marshal comments: %w", err)
}
@@ -288,7 +443,7 @@ func GetDiscussionComments(getGQLClient GetGQLClientFn, t translations.Translati
func ListDiscussionCategories(getGQLClient GetGQLClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
return mcp.NewTool("list_discussion_categories",
- mcp.WithDescription(t("TOOL_LIST_DISCUSSION_CATEGORIES_DESCRIPTION", "List discussion categories with their id and name, for a repository")),
+ mcp.WithDescription(t("TOOL_LIST_DISCUSSION_CATEGORIES_DESCRIPTION", "List discussion categories with their id and name, for a repository or organisation.")),
mcp.WithToolAnnotation(mcp.ToolAnnotation{
Title: t("TOOL_LIST_DISCUSSION_CATEGORIES_USER_TITLE", "List discussion categories"),
ReadOnlyHint: ToBoolPtr(true),
@@ -298,58 +453,29 @@ func ListDiscussionCategories(getGQLClient GetGQLClientFn, t translations.Transl
mcp.Description("Repository owner"),
),
mcp.WithString("repo",
- mcp.Required(),
- mcp.Description("Repository name"),
- ),
- mcp.WithNumber("first",
- mcp.Description("Number of categories to return per page (min 1, max 100)"),
- mcp.Min(1),
- mcp.Max(100),
- ),
- mcp.WithNumber("last",
- mcp.Description("Number of categories to return from the end (min 1, max 100)"),
- mcp.Min(1),
- mcp.Max(100),
- ),
- mcp.WithString("after",
- mcp.Description("Cursor for pagination, use the 'after' field from the previous response"),
- ),
- mcp.WithString("before",
- mcp.Description("Cursor for pagination, use the 'before' field from the previous response"),
+ mcp.Description("Repository name. If not provided, discussion categories will be queried at the organisation level."),
),
),
func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
- // Decode params
- var params struct {
- Owner string
- Repo string
- First int32
- Last int32
- After string
- Before string
- }
- if err := mapstructure.Decode(request.Params.Arguments, ¶ms); err != nil {
+ owner, err := RequiredParam[string](request, "owner")
+ if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
-
- // Validate pagination parameters
- if params.First != 0 && params.Last != 0 {
- return mcp.NewToolResultError("only one of 'first' or 'last' may be specified"), nil
- }
- if params.After != "" && params.Before != "" {
- return mcp.NewToolResultError("only one of 'after' or 'before' may be specified"), nil
- }
- if params.After != "" && params.Last != 0 {
- return mcp.NewToolResultError("'after' cannot be used with 'last'. Did you mean to use 'before' instead?"), nil
+ repo, err := OptionalParam[string](request, "repo")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
}
- if params.Before != "" && params.First != 0 {
- return mcp.NewToolResultError("'before' cannot be used with 'first'. Did you mean to use 'after' instead?"), nil
+ // when not provided, default to the .github repository
+ // this will query discussion categories at the organisation level
+ if repo == "" {
+ repo = ".github"
}
client, err := getGQLClient(ctx)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("failed to get GitHub GQL client: %v", err)), nil
}
+
var q struct {
Repository struct {
DiscussionCategories struct {
@@ -357,16 +483,25 @@ func ListDiscussionCategories(getGQLClient GetGQLClientFn, t translations.Transl
ID githubv4.ID
Name githubv4.String
}
- } `graphql:"discussionCategories(first: 100)"`
+ PageInfo struct {
+ HasNextPage githubv4.Boolean
+ HasPreviousPage githubv4.Boolean
+ StartCursor githubv4.String
+ EndCursor githubv4.String
+ }
+ TotalCount int
+ } `graphql:"discussionCategories(first: $first)"`
} `graphql:"repository(owner: $owner, name: $repo)"`
}
vars := map[string]interface{}{
- "owner": githubv4.String(params.Owner),
- "repo": githubv4.String(params.Repo),
+ "owner": githubv4.String(owner),
+ "repo": githubv4.String(repo),
+ "first": githubv4.Int(25),
}
if err := client.Query(ctx, &q, vars); err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
+
var categories []map[string]string
for _, c := range q.Repository.DiscussionCategories.Nodes {
categories = append(categories, map[string]string{
@@ -374,7 +509,20 @@ func ListDiscussionCategories(getGQLClient GetGQLClientFn, t translations.Transl
"name": string(c.Name),
})
}
- out, err := json.Marshal(categories)
+
+ // Create response with pagination info
+ response := map[string]interface{}{
+ "categories": categories,
+ "pageInfo": map[string]interface{}{
+ "hasNextPage": q.Repository.DiscussionCategories.PageInfo.HasNextPage,
+ "hasPreviousPage": q.Repository.DiscussionCategories.PageInfo.HasPreviousPage,
+ "startCursor": string(q.Repository.DiscussionCategories.PageInfo.StartCursor),
+ "endCursor": string(q.Repository.DiscussionCategories.PageInfo.EndCursor),
+ },
+ "totalCount": q.Repository.DiscussionCategories.TotalCount,
+ }
+
+ out, err := json.Marshal(response)
if err != nil {
return nil, fmt.Errorf("failed to marshal discussion categories: %w", err)
}
diff --git a/pkg/github/discussions_test.go b/pkg/github/discussions_test.go
index c6688a519..beef2effe 100644
--- a/pkg/github/discussions_test.go
+++ b/pkg/github/discussions_test.go
@@ -9,7 +9,7 @@ import (
"github.com/github/github-mcp-server/internal/githubv4mock"
"github.com/github/github-mcp-server/pkg/translations"
- "github.com/google/go-github/v73/github"
+ "github.com/google/go-github/v74/github"
"github.com/shurcooL/githubv4"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -17,85 +17,253 @@ import (
var (
discussionsGeneral = []map[string]any{
- {"number": 1, "title": "Discussion 1 title", "createdAt": "2023-01-01T00:00:00Z", "url": "https://github.com/owner/repo/discussions/1", "category": map[string]any{"name": "General"}},
- {"number": 3, "title": "Discussion 3 title", "createdAt": "2023-03-01T00:00:00Z", "url": "https://github.com/owner/repo/discussions/3", "category": map[string]any{"name": "General"}},
+ {"number": 1, "title": "Discussion 1 title", "createdAt": "2023-01-01T00:00:00Z", "updatedAt": "2023-01-01T00:00:00Z", "author": map[string]any{"login": "user1"}, "url": "https://github.com/owner/repo/discussions/1", "category": map[string]any{"name": "General"}},
+ {"number": 3, "title": "Discussion 3 title", "createdAt": "2023-03-01T00:00:00Z", "updatedAt": "2023-02-01T00:00:00Z", "author": map[string]any{"login": "user1"}, "url": "https://github.com/owner/repo/discussions/3", "category": map[string]any{"name": "General"}},
}
discussionsAll = []map[string]any{
- {"number": 1, "title": "Discussion 1 title", "createdAt": "2023-01-01T00:00:00Z", "url": "https://github.com/owner/repo/discussions/1", "category": map[string]any{"name": "General"}},
- {"number": 2, "title": "Discussion 2 title", "createdAt": "2023-02-01T00:00:00Z", "url": "https://github.com/owner/repo/discussions/2", "category": map[string]any{"name": "Questions"}},
- {"number": 3, "title": "Discussion 3 title", "createdAt": "2023-03-01T00:00:00Z", "url": "https://github.com/owner/repo/discussions/3", "category": map[string]any{"name": "General"}},
+ {
+ "number": 1,
+ "title": "Discussion 1 title",
+ "createdAt": "2023-01-01T00:00:00Z",
+ "updatedAt": "2023-01-01T00:00:00Z",
+ "author": map[string]any{"login": "user1"},
+ "url": "https://github.com/owner/repo/discussions/1",
+ "category": map[string]any{"name": "General"},
+ },
+ {
+ "number": 2,
+ "title": "Discussion 2 title",
+ "createdAt": "2023-02-01T00:00:00Z",
+ "updatedAt": "2023-02-01T00:00:00Z",
+ "author": map[string]any{"login": "user2"},
+ "url": "https://github.com/owner/repo/discussions/2",
+ "category": map[string]any{"name": "Questions"},
+ },
+ {
+ "number": 3,
+ "title": "Discussion 3 title",
+ "createdAt": "2023-03-01T00:00:00Z",
+ "updatedAt": "2023-03-01T00:00:00Z",
+ "author": map[string]any{"login": "user3"},
+ "url": "https://github.com/owner/repo/discussions/3",
+ "category": map[string]any{"name": "General"},
+ },
}
+
+ discussionsOrgLevel = []map[string]any{
+ {
+ "number": 1,
+ "title": "Org Discussion 1 - Community Guidelines",
+ "createdAt": "2023-01-15T00:00:00Z",
+ "updatedAt": "2023-01-15T00:00:00Z",
+ "author": map[string]any{"login": "org-admin"},
+ "url": "https://github.com/owner/.github/discussions/1",
+ "category": map[string]any{"name": "Announcements"},
+ },
+ {
+ "number": 2,
+ "title": "Org Discussion 2 - Roadmap 2023",
+ "createdAt": "2023-02-20T00:00:00Z",
+ "updatedAt": "2023-02-20T00:00:00Z",
+ "author": map[string]any{"login": "org-admin"},
+ "url": "https://github.com/owner/.github/discussions/2",
+ "category": map[string]any{"name": "General"},
+ },
+ {
+ "number": 3,
+ "title": "Org Discussion 3 - Roadmap 2024",
+ "createdAt": "2023-02-20T00:00:00Z",
+ "updatedAt": "2023-02-20T00:00:00Z",
+ "author": map[string]any{"login": "org-admin"},
+ "url": "https://github.com/owner/.github/discussions/3",
+ "category": map[string]any{"name": "General"},
+ },
+ {
+ "number": 4,
+ "title": "Org Discussion 4 - Roadmap 2025",
+ "createdAt": "2023-02-20T00:00:00Z",
+ "updatedAt": "2023-02-20T00:00:00Z",
+ "author": map[string]any{"login": "org-admin"},
+ "url": "https://github.com/owner/.github/discussions/4",
+ "category": map[string]any{"name": "General"},
+ },
+ }
+
+ // Ordered mock responses
+ discussionsOrderedCreatedAsc = []map[string]any{
+ discussionsAll[0], // Discussion 1 (created 2023-01-01)
+ discussionsAll[1], // Discussion 2 (created 2023-02-01)
+ discussionsAll[2], // Discussion 3 (created 2023-03-01)
+ }
+
+ discussionsOrderedUpdatedDesc = []map[string]any{
+ discussionsAll[2], // Discussion 3 (updated 2023-03-01)
+ discussionsAll[1], // Discussion 2 (updated 2023-02-01)
+ discussionsAll[0], // Discussion 1 (updated 2023-01-01)
+ }
+
+ // only 'General' category discussions ordered by created date descending
+ discussionsGeneralOrderedDesc = []map[string]any{
+ discussionsGeneral[1], // Discussion 3 (created 2023-03-01)
+ discussionsGeneral[0], // Discussion 1 (created 2023-01-01)
+ }
+
mockResponseListAll = githubv4mock.DataResponse(map[string]any{
"repository": map[string]any{
- "discussions": map[string]any{"nodes": discussionsAll},
+ "discussions": map[string]any{
+ "nodes": discussionsAll,
+ "pageInfo": map[string]any{
+ "hasNextPage": false,
+ "hasPreviousPage": false,
+ "startCursor": "",
+ "endCursor": "",
+ },
+ "totalCount": 3,
+ },
},
})
mockResponseListGeneral = githubv4mock.DataResponse(map[string]any{
"repository": map[string]any{
- "discussions": map[string]any{"nodes": discussionsGeneral},
+ "discussions": map[string]any{
+ "nodes": discussionsGeneral,
+ "pageInfo": map[string]any{
+ "hasNextPage": false,
+ "hasPreviousPage": false,
+ "startCursor": "",
+ "endCursor": "",
+ },
+ "totalCount": 2,
+ },
+ },
+ })
+ mockResponseOrderedCreatedAsc = githubv4mock.DataResponse(map[string]any{
+ "repository": map[string]any{
+ "discussions": map[string]any{
+ "nodes": discussionsOrderedCreatedAsc,
+ "pageInfo": map[string]any{
+ "hasNextPage": false,
+ "hasPreviousPage": false,
+ "startCursor": "",
+ "endCursor": "",
+ },
+ "totalCount": 3,
+ },
+ },
+ })
+ mockResponseOrderedUpdatedDesc = githubv4mock.DataResponse(map[string]any{
+ "repository": map[string]any{
+ "discussions": map[string]any{
+ "nodes": discussionsOrderedUpdatedDesc,
+ "pageInfo": map[string]any{
+ "hasNextPage": false,
+ "hasPreviousPage": false,
+ "startCursor": "",
+ "endCursor": "",
+ },
+ "totalCount": 3,
+ },
+ },
+ })
+ mockResponseGeneralOrderedDesc = githubv4mock.DataResponse(map[string]any{
+ "repository": map[string]any{
+ "discussions": map[string]any{
+ "nodes": discussionsGeneralOrderedDesc,
+ "pageInfo": map[string]any{
+ "hasNextPage": false,
+ "hasPreviousPage": false,
+ "startCursor": "",
+ "endCursor": "",
+ },
+ "totalCount": 2,
+ },
+ },
+ })
+
+ mockResponseOrgLevel = githubv4mock.DataResponse(map[string]any{
+ "repository": map[string]any{
+ "discussions": map[string]any{
+ "nodes": discussionsOrgLevel,
+ "pageInfo": map[string]any{
+ "hasNextPage": false,
+ "hasPreviousPage": false,
+ "startCursor": "",
+ "endCursor": "",
+ },
+ "totalCount": 4,
+ },
},
})
+
mockErrorRepoNotFound = githubv4mock.ErrorResponse("repository not found")
)
func Test_ListDiscussions(t *testing.T) {
mockClient := githubv4.NewClient(nil)
- // Verify tool definition and schema
toolDef, _ := ListDiscussions(stubGetGQLClientFn(mockClient), translations.NullTranslationHelper)
assert.Equal(t, "list_discussions", toolDef.Name)
assert.NotEmpty(t, toolDef.Description)
assert.Contains(t, toolDef.InputSchema.Properties, "owner")
assert.Contains(t, toolDef.InputSchema.Properties, "repo")
- assert.ElementsMatch(t, toolDef.InputSchema.Required, []string{"owner", "repo"})
-
- // mock for the call to ListDiscussions without category filter
- var qDiscussions struct {
- Repository struct {
- Discussions struct {
- Nodes []struct {
- Number githubv4.Int
- Title githubv4.String
- CreatedAt githubv4.DateTime
- Category struct {
- Name githubv4.String
- } `graphql:"category"`
- URL githubv4.String `graphql:"url"`
- }
- } `graphql:"discussions(first: 100)"`
- } `graphql:"repository(owner: $owner, name: $repo)"`
- }
-
- // mock for the call to get discussions with category filter
- var qDiscussionsFiltered struct {
- Repository struct {
- Discussions struct {
- Nodes []struct {
- Number githubv4.Int
- Title githubv4.String
- CreatedAt githubv4.DateTime
- Category struct {
- Name githubv4.String
- } `graphql:"category"`
- URL githubv4.String `graphql:"url"`
- }
- } `graphql:"discussions(first: 100, categoryId: $categoryId)"`
- } `graphql:"repository(owner: $owner, name: $repo)"`
- }
+ assert.Contains(t, toolDef.InputSchema.Properties, "orderBy")
+ assert.Contains(t, toolDef.InputSchema.Properties, "direction")
+ assert.ElementsMatch(t, toolDef.InputSchema.Required, []string{"owner"})
+ // Variables matching what GraphQL receives after JSON marshaling/unmarshaling
varsListAll := map[string]interface{}{
- "owner": githubv4.String("owner"),
- "repo": githubv4.String("repo"),
+ "owner": "owner",
+ "repo": "repo",
+ "first": float64(30),
+ "after": (*string)(nil),
}
varsRepoNotFound := map[string]interface{}{
- "owner": githubv4.String("owner"),
- "repo": githubv4.String("nonexistent-repo"),
+ "owner": "owner",
+ "repo": "nonexistent-repo",
+ "first": float64(30),
+ "after": (*string)(nil),
}
varsDiscussionsFiltered := map[string]interface{}{
- "owner": githubv4.String("owner"),
- "repo": githubv4.String("repo"),
- "categoryId": githubv4.ID("DIC_kwDOABC123"),
+ "owner": "owner",
+ "repo": "repo",
+ "categoryId": "DIC_kwDOABC123",
+ "first": float64(30),
+ "after": (*string)(nil),
+ }
+
+ varsOrderByCreatedAsc := map[string]interface{}{
+ "owner": "owner",
+ "repo": "repo",
+ "orderByField": "CREATED_AT",
+ "orderByDirection": "ASC",
+ "first": float64(30),
+ "after": (*string)(nil),
+ }
+
+ varsOrderByUpdatedDesc := map[string]interface{}{
+ "owner": "owner",
+ "repo": "repo",
+ "orderByField": "UPDATED_AT",
+ "orderByDirection": "DESC",
+ "first": float64(30),
+ "after": (*string)(nil),
+ }
+
+ varsCategoryWithOrder := map[string]interface{}{
+ "owner": "owner",
+ "repo": "repo",
+ "categoryId": "DIC_kwDOABC123",
+ "orderByField": "CREATED_AT",
+ "orderByDirection": "DESC",
+ "first": float64(30),
+ "after": (*string)(nil),
+ }
+
+ varsOrgLevel := map[string]interface{}{
+ "owner": "owner",
+ "repo": ".github", // This is what gets set when repo is not provided
+ "first": float64(30),
+ "after": (*string)(nil),
}
tests := []struct {
@@ -104,6 +272,7 @@ func Test_ListDiscussions(t *testing.T) {
expectError bool
errContains string
expectedCount int
+ verifyOrder func(t *testing.T, discussions []*github.Discussion)
}{
{
name: "list all discussions without category filter",
@@ -124,6 +293,80 @@ func Test_ListDiscussions(t *testing.T) {
expectError: false,
expectedCount: 2, // Only General discussions (matching the category ID)
},
+ {
+ name: "order by created at ascending",
+ reqParams: map[string]interface{}{
+ "owner": "owner",
+ "repo": "repo",
+ "orderBy": "CREATED_AT",
+ "direction": "ASC",
+ },
+ expectError: false,
+ expectedCount: 3,
+ verifyOrder: func(t *testing.T, discussions []*github.Discussion) {
+ // Verify discussions are ordered by created date ascending
+ require.Len(t, discussions, 3)
+ assert.Equal(t, 1, *discussions[0].Number, "First should be discussion 1 (created 2023-01-01)")
+ assert.Equal(t, 2, *discussions[1].Number, "Second should be discussion 2 (created 2023-02-01)")
+ assert.Equal(t, 3, *discussions[2].Number, "Third should be discussion 3 (created 2023-03-01)")
+ },
+ },
+ {
+ name: "order by updated at descending",
+ reqParams: map[string]interface{}{
+ "owner": "owner",
+ "repo": "repo",
+ "orderBy": "UPDATED_AT",
+ "direction": "DESC",
+ },
+ expectError: false,
+ expectedCount: 3,
+ verifyOrder: func(t *testing.T, discussions []*github.Discussion) {
+ // Verify discussions are ordered by updated date descending
+ require.Len(t, discussions, 3)
+ assert.Equal(t, 3, *discussions[0].Number, "First should be discussion 3 (updated 2023-03-01)")
+ assert.Equal(t, 2, *discussions[1].Number, "Second should be discussion 2 (updated 2023-02-01)")
+ assert.Equal(t, 1, *discussions[2].Number, "Third should be discussion 1 (updated 2023-01-01)")
+ },
+ },
+ {
+ name: "filter by category with order",
+ reqParams: map[string]interface{}{
+ "owner": "owner",
+ "repo": "repo",
+ "category": "DIC_kwDOABC123",
+ "orderBy": "CREATED_AT",
+ "direction": "DESC",
+ },
+ expectError: false,
+ expectedCount: 2,
+ verifyOrder: func(t *testing.T, discussions []*github.Discussion) {
+ // Verify only General discussions, ordered by created date descending
+ require.Len(t, discussions, 2)
+ assert.Equal(t, 3, *discussions[0].Number, "First should be discussion 3 (created 2023-03-01)")
+ assert.Equal(t, 1, *discussions[1].Number, "Second should be discussion 1 (created 2023-01-01)")
+ },
+ },
+ {
+ name: "order by without direction (should not use ordering)",
+ reqParams: map[string]interface{}{
+ "owner": "owner",
+ "repo": "repo",
+ "orderBy": "CREATED_AT",
+ },
+ expectError: false,
+ expectedCount: 3,
+ },
+ {
+ name: "direction without order by (should not use ordering)",
+ reqParams: map[string]interface{}{
+ "owner": "owner",
+ "repo": "repo",
+ "direction": "DESC",
+ },
+ expectError: false,
+ expectedCount: 3,
+ },
{
name: "repository not found error",
reqParams: map[string]interface{}{
@@ -133,23 +376,54 @@ func Test_ListDiscussions(t *testing.T) {
expectError: true,
errContains: "repository not found",
},
+ {
+ name: "list org-level discussions (no repo provided)",
+ reqParams: map[string]interface{}{
+ "owner": "owner",
+ // repo is not provided, it will default to ".github"
+ },
+ expectError: false,
+ expectedCount: 4,
+ },
}
+ // Define the actual query strings that match the implementation
+ qBasicNoOrder := "query($after:String$first:Int!$owner:String!$repo:String!){repository(owner: $owner, name: $repo){discussions(first: $first, after: $after){nodes{number,title,createdAt,updatedAt,author{login},category{name},url},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor},totalCount}}}"
+ qWithCategoryNoOrder := "query($after:String$categoryId:ID!$first:Int!$owner:String!$repo:String!){repository(owner: $owner, name: $repo){discussions(first: $first, after: $after, categoryId: $categoryId){nodes{number,title,createdAt,updatedAt,author{login},category{name},url},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor},totalCount}}}"
+ qBasicWithOrder := "query($after:String$first:Int!$orderByDirection:OrderDirection!$orderByField:DiscussionOrderField!$owner:String!$repo:String!){repository(owner: $owner, name: $repo){discussions(first: $first, after: $after, orderBy: { field: $orderByField, direction: $orderByDirection }){nodes{number,title,createdAt,updatedAt,author{login},category{name},url},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor},totalCount}}}"
+ qWithCategoryAndOrder := "query($after:String$categoryId:ID!$first:Int!$orderByDirection:OrderDirection!$orderByField:DiscussionOrderField!$owner:String!$repo:String!){repository(owner: $owner, name: $repo){discussions(first: $first, after: $after, categoryId: $categoryId, orderBy: { field: $orderByField, direction: $orderByDirection }){nodes{number,title,createdAt,updatedAt,author{login},category{name},url},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor},totalCount}}}"
+
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
var httpClient *http.Client
switch tc.name {
case "list all discussions without category filter":
- // Simple case - no category filter
- matcher := githubv4mock.NewQueryMatcher(qDiscussions, varsListAll, mockResponseListAll)
+ matcher := githubv4mock.NewQueryMatcher(qBasicNoOrder, varsListAll, mockResponseListAll)
httpClient = githubv4mock.NewMockedHTTPClient(matcher)
case "filter by category ID":
- // Simple case - category filter using category ID directly
- matcher := githubv4mock.NewQueryMatcher(qDiscussionsFiltered, varsDiscussionsFiltered, mockResponseListGeneral)
+ matcher := githubv4mock.NewQueryMatcher(qWithCategoryNoOrder, varsDiscussionsFiltered, mockResponseListGeneral)
+ httpClient = githubv4mock.NewMockedHTTPClient(matcher)
+ case "order by created at ascending":
+ matcher := githubv4mock.NewQueryMatcher(qBasicWithOrder, varsOrderByCreatedAsc, mockResponseOrderedCreatedAsc)
+ httpClient = githubv4mock.NewMockedHTTPClient(matcher)
+ case "order by updated at descending":
+ matcher := githubv4mock.NewQueryMatcher(qBasicWithOrder, varsOrderByUpdatedDesc, mockResponseOrderedUpdatedDesc)
+ httpClient = githubv4mock.NewMockedHTTPClient(matcher)
+ case "filter by category with order":
+ matcher := githubv4mock.NewQueryMatcher(qWithCategoryAndOrder, varsCategoryWithOrder, mockResponseGeneralOrderedDesc)
+ httpClient = githubv4mock.NewMockedHTTPClient(matcher)
+ case "order by without direction (should not use ordering)":
+ matcher := githubv4mock.NewQueryMatcher(qBasicNoOrder, varsListAll, mockResponseListAll)
+ httpClient = githubv4mock.NewMockedHTTPClient(matcher)
+ case "direction without order by (should not use ordering)":
+ matcher := githubv4mock.NewQueryMatcher(qBasicNoOrder, varsListAll, mockResponseListAll)
httpClient = githubv4mock.NewMockedHTTPClient(matcher)
case "repository not found error":
- matcher := githubv4mock.NewQueryMatcher(qDiscussions, varsRepoNotFound, mockErrorRepoNotFound)
+ matcher := githubv4mock.NewQueryMatcher(qBasicNoOrder, varsRepoNotFound, mockErrorRepoNotFound)
+ httpClient = githubv4mock.NewMockedHTTPClient(matcher)
+ case "list org-level discussions (no repo provided)":
+ matcher := githubv4mock.NewQueryMatcher(qBasicNoOrder, varsOrgLevel, mockResponseOrgLevel)
httpClient = githubv4mock.NewMockedHTTPClient(matcher)
}
@@ -167,15 +441,30 @@ func Test_ListDiscussions(t *testing.T) {
}
require.NoError(t, err)
- var returnedDiscussions []*github.Discussion
- err = json.Unmarshal([]byte(text), &returnedDiscussions)
+ // Parse the structured response with pagination info
+ var response struct {
+ Discussions []*github.Discussion `json:"discussions"`
+ PageInfo struct {
+ HasNextPage bool `json:"hasNextPage"`
+ HasPreviousPage bool `json:"hasPreviousPage"`
+ StartCursor string `json:"startCursor"`
+ EndCursor string `json:"endCursor"`
+ } `json:"pageInfo"`
+ TotalCount int `json:"totalCount"`
+ }
+ err = json.Unmarshal([]byte(text), &response)
require.NoError(t, err)
- assert.Len(t, returnedDiscussions, tc.expectedCount, "Expected %d discussions, got %d", tc.expectedCount, len(returnedDiscussions))
+ assert.Len(t, response.Discussions, tc.expectedCount, "Expected %d discussions, got %d", tc.expectedCount, len(response.Discussions))
+
+ // Verify order if verifyOrder function is provided
+ if tc.verifyOrder != nil {
+ tc.verifyOrder(t, response.Discussions)
+ }
// Verify that all returned discussions have a category if filtered
if _, hasCategory := tc.reqParams["category"]; hasCategory {
- for _, discussion := range returnedDiscussions {
+ for _, discussion := range response.Discussions {
require.NotNil(t, discussion.DiscussionCategory, "Discussion should have category")
assert.NotEmpty(t, *discussion.DiscussionCategory.Name, "Discussion should have category name")
}
@@ -194,23 +483,13 @@ func Test_GetDiscussion(t *testing.T) {
assert.Contains(t, toolDef.InputSchema.Properties, "discussionNumber")
assert.ElementsMatch(t, toolDef.InputSchema.Required, []string{"owner", "repo", "discussionNumber"})
- var q struct {
- Repository struct {
- Discussion struct {
- Number githubv4.Int
- Body githubv4.String
- CreatedAt githubv4.DateTime
- URL githubv4.String `graphql:"url"`
- Category struct {
- Name githubv4.String
- } `graphql:"category"`
- } `graphql:"discussion(number: $discussionNumber)"`
- } `graphql:"repository(owner: $owner, name: $repo)"`
- }
+ // Use exact string query that matches implementation output
+ qGetDiscussion := "query($discussionNumber:Int!$owner:String!$repo:String!){repository(owner: $owner, name: $repo){discussion(number: $discussionNumber){number,title,body,createdAt,url,category{name}}}}"
+
vars := map[string]interface{}{
- "owner": githubv4.String("owner"),
- "repo": githubv4.String("repo"),
- "discussionNumber": githubv4.Int(1),
+ "owner": "owner",
+ "repo": "repo",
+ "discussionNumber": float64(1),
}
tests := []struct {
name string
@@ -224,6 +503,7 @@ func Test_GetDiscussion(t *testing.T) {
response: githubv4mock.DataResponse(map[string]any{
"repository": map[string]any{"discussion": map[string]any{
"number": 1,
+ "title": "Test Discussion Title",
"body": "This is a test discussion",
"url": "https://github.com/owner/repo/discussions/1",
"createdAt": "2025-04-25T12:00:00Z",
@@ -234,6 +514,7 @@ func Test_GetDiscussion(t *testing.T) {
expected: &github.Discussion{
HTMLURL: github.Ptr("https://github.com/owner/repo/discussions/1"),
Number: github.Ptr(1),
+ Title: github.Ptr("Test Discussion Title"),
Body: github.Ptr("This is a test discussion"),
CreatedAt: &github.Timestamp{Time: time.Date(2025, 4, 25, 12, 0, 0, 0, time.UTC)},
DiscussionCategory: &github.DiscussionCategory{
@@ -250,7 +531,7 @@ func Test_GetDiscussion(t *testing.T) {
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
- matcher := githubv4mock.NewQueryMatcher(q, vars, tc.response)
+ matcher := githubv4mock.NewQueryMatcher(qGetDiscussion, vars, tc.response)
httpClient := githubv4mock.NewMockedHTTPClient(matcher)
gqlClient := githubv4.NewClient(httpClient)
_, handler := GetDiscussion(stubGetGQLClientFn(gqlClient), translations.NullTranslationHelper)
@@ -270,6 +551,7 @@ func Test_GetDiscussion(t *testing.T) {
require.NoError(t, json.Unmarshal([]byte(text), &out))
assert.Equal(t, *tc.expected.HTMLURL, *out.HTMLURL)
assert.Equal(t, *tc.expected.Number, *out.Number)
+ assert.Equal(t, *tc.expected.Title, *out.Title)
assert.Equal(t, *tc.expected.Body, *out.Body)
// Check category label
assert.Equal(t, *tc.expected.DiscussionCategory.Name, *out.DiscussionCategory.Name)
@@ -287,22 +569,18 @@ func Test_GetDiscussionComments(t *testing.T) {
assert.Contains(t, toolDef.InputSchema.Properties, "discussionNumber")
assert.ElementsMatch(t, toolDef.InputSchema.Required, []string{"owner", "repo", "discussionNumber"})
- var q struct {
- Repository struct {
- Discussion struct {
- Comments struct {
- Nodes []struct {
- Body githubv4.String
- }
- } `graphql:"comments(first:100)"`
- } `graphql:"discussion(number: $discussionNumber)"`
- } `graphql:"repository(owner: $owner, name: $repo)"`
- }
+ // Use exact string query that matches implementation output
+ qGetComments := "query($after:String$discussionNumber:Int!$first:Int!$owner:String!$repo:String!){repository(owner: $owner, name: $repo){discussion(number: $discussionNumber){comments(first: $first, after: $after){nodes{body},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor},totalCount}}}}"
+
+ // Variables matching what GraphQL receives after JSON marshaling/unmarshaling
vars := map[string]interface{}{
- "owner": githubv4.String("owner"),
- "repo": githubv4.String("repo"),
- "discussionNumber": githubv4.Int(1),
+ "owner": "owner",
+ "repo": "repo",
+ "discussionNumber": float64(1),
+ "first": float64(30),
+ "after": (*string)(nil),
}
+
mockResponse := githubv4mock.DataResponse(map[string]any{
"repository": map[string]any{
"discussion": map[string]any{
@@ -311,11 +589,18 @@ func Test_GetDiscussionComments(t *testing.T) {
{"body": "This is the first comment"},
{"body": "This is the second comment"},
},
+ "pageInfo": map[string]any{
+ "hasNextPage": false,
+ "hasPreviousPage": false,
+ "startCursor": "",
+ "endCursor": "",
+ },
+ "totalCount": 2,
},
},
},
})
- matcher := githubv4mock.NewQueryMatcher(q, vars, mockResponse)
+ matcher := githubv4mock.NewQueryMatcher(qGetComments, vars, mockResponse)
httpClient := githubv4mock.NewMockedHTTPClient(matcher)
gqlClient := githubv4.NewClient(httpClient)
_, handler := GetDiscussionComments(stubGetGQLClientFn(gqlClient), translations.NullTranslationHelper)
@@ -331,62 +616,163 @@ func Test_GetDiscussionComments(t *testing.T) {
textContent := getTextResult(t, result)
- var returnedComments []*github.IssueComment
- err = json.Unmarshal([]byte(textContent.Text), &returnedComments)
+ // (Lines removed)
+
+ var response struct {
+ Comments []*github.IssueComment `json:"comments"`
+ PageInfo struct {
+ HasNextPage bool `json:"hasNextPage"`
+ HasPreviousPage bool `json:"hasPreviousPage"`
+ StartCursor string `json:"startCursor"`
+ EndCursor string `json:"endCursor"`
+ } `json:"pageInfo"`
+ TotalCount int `json:"totalCount"`
+ }
+ err = json.Unmarshal([]byte(textContent.Text), &response)
require.NoError(t, err)
- assert.Len(t, returnedComments, 2)
+ assert.Len(t, response.Comments, 2)
expectedBodies := []string{"This is the first comment", "This is the second comment"}
- for i, comment := range returnedComments {
+ for i, comment := range response.Comments {
assert.Equal(t, expectedBodies[i], *comment.Body)
}
}
func Test_ListDiscussionCategories(t *testing.T) {
- var q struct {
- Repository struct {
- DiscussionCategories struct {
- Nodes []struct {
- ID githubv4.ID
- Name githubv4.String
- }
- } `graphql:"discussionCategories(first: 100)"`
- } `graphql:"repository(owner: $owner, name: $repo)"`
+ mockClient := githubv4.NewClient(nil)
+ toolDef, _ := ListDiscussionCategories(stubGetGQLClientFn(mockClient), translations.NullTranslationHelper)
+ assert.Equal(t, "list_discussion_categories", toolDef.Name)
+ assert.NotEmpty(t, toolDef.Description)
+ assert.Contains(t, toolDef.Description, "or organisation")
+ assert.Contains(t, toolDef.InputSchema.Properties, "owner")
+ assert.Contains(t, toolDef.InputSchema.Properties, "repo")
+ assert.ElementsMatch(t, toolDef.InputSchema.Required, []string{"owner"})
+
+ // Use exact string query that matches implementation output
+ qListCategories := "query($first:Int!$owner:String!$repo:String!){repository(owner: $owner, name: $repo){discussionCategories(first: $first){nodes{id,name},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor},totalCount}}}"
+
+ // Variables for repository-level categories
+ varsRepo := map[string]interface{}{
+ "owner": "owner",
+ "repo": "repo",
+ "first": float64(25),
}
- vars := map[string]interface{}{
- "owner": githubv4.String("owner"),
- "repo": githubv4.String("repo"),
+
+ // Variables for organization-level categories (using .github repo)
+ varsOrg := map[string]interface{}{
+ "owner": "owner",
+ "repo": ".github",
+ "first": float64(25),
}
- mockResp := githubv4mock.DataResponse(map[string]any{
+
+ mockRespRepo := githubv4mock.DataResponse(map[string]any{
"repository": map[string]any{
"discussionCategories": map[string]any{
"nodes": []map[string]any{
{"id": "123", "name": "CategoryOne"},
{"id": "456", "name": "CategoryTwo"},
},
+ "pageInfo": map[string]any{
+ "hasNextPage": false,
+ "hasPreviousPage": false,
+ "startCursor": "",
+ "endCursor": "",
+ },
+ "totalCount": 2,
},
},
})
- matcher := githubv4mock.NewQueryMatcher(q, vars, mockResp)
- httpClient := githubv4mock.NewMockedHTTPClient(matcher)
- gqlClient := githubv4.NewClient(httpClient)
- tool, handler := ListDiscussionCategories(stubGetGQLClientFn(gqlClient), translations.NullTranslationHelper)
- assert.Equal(t, "list_discussion_categories", tool.Name)
- assert.NotEmpty(t, tool.Description)
- assert.Contains(t, tool.InputSchema.Properties, "owner")
- assert.Contains(t, tool.InputSchema.Properties, "repo")
- assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo"})
+ mockRespOrg := githubv4mock.DataResponse(map[string]any{
+ "repository": map[string]any{
+ "discussionCategories": map[string]any{
+ "nodes": []map[string]any{
+ {"id": "789", "name": "Announcements"},
+ {"id": "101", "name": "General"},
+ {"id": "112", "name": "Ideas"},
+ },
+ "pageInfo": map[string]any{
+ "hasNextPage": false,
+ "hasPreviousPage": false,
+ "startCursor": "",
+ "endCursor": "",
+ },
+ "totalCount": 3,
+ },
+ },
+ })
+
+ tests := []struct {
+ name string
+ reqParams map[string]interface{}
+ vars map[string]interface{}
+ mockResponse githubv4mock.GQLResponse
+ expectError bool
+ expectedCount int
+ expectedCategories []map[string]string
+ }{
+ {
+ name: "list repository-level discussion categories",
+ reqParams: map[string]interface{}{
+ "owner": "owner",
+ "repo": "repo",
+ },
+ vars: varsRepo,
+ mockResponse: mockRespRepo,
+ expectError: false,
+ expectedCount: 2,
+ expectedCategories: []map[string]string{
+ {"id": "123", "name": "CategoryOne"},
+ {"id": "456", "name": "CategoryTwo"},
+ },
+ },
+ {
+ name: "list org-level discussion categories (no repo provided)",
+ reqParams: map[string]interface{}{
+ "owner": "owner",
+ // repo is not provided, it will default to ".github"
+ },
+ vars: varsOrg,
+ mockResponse: mockRespOrg,
+ expectError: false,
+ expectedCount: 3,
+ expectedCategories: []map[string]string{
+ {"id": "789", "name": "Announcements"},
+ {"id": "101", "name": "General"},
+ {"id": "112", "name": "Ideas"},
+ },
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ matcher := githubv4mock.NewQueryMatcher(qListCategories, tc.vars, tc.mockResponse)
+ httpClient := githubv4mock.NewMockedHTTPClient(matcher)
+ gqlClient := githubv4.NewClient(httpClient)
- request := createMCPRequest(map[string]interface{}{"owner": "owner", "repo": "repo"})
- result, err := handler(context.Background(), request)
- require.NoError(t, err)
+ _, handler := ListDiscussionCategories(stubGetGQLClientFn(gqlClient), translations.NullTranslationHelper)
- text := getTextResult(t, result).Text
- var categories []map[string]string
- require.NoError(t, json.Unmarshal([]byte(text), &categories))
- assert.Len(t, categories, 2)
- assert.Equal(t, "123", categories[0]["id"])
- assert.Equal(t, "CategoryOne", categories[0]["name"])
- assert.Equal(t, "456", categories[1]["id"])
- assert.Equal(t, "CategoryTwo", categories[1]["name"])
+ req := createMCPRequest(tc.reqParams)
+ res, err := handler(context.Background(), req)
+ text := getTextResult(t, res).Text
+
+ if tc.expectError {
+ require.True(t, res.IsError)
+ return
+ }
+ require.NoError(t, err)
+
+ var response struct {
+ Categories []map[string]string `json:"categories"`
+ PageInfo struct {
+ HasNextPage bool `json:"hasNextPage"`
+ HasPreviousPage bool `json:"hasPreviousPage"`
+ StartCursor string `json:"startCursor"`
+ EndCursor string `json:"endCursor"`
+ } `json:"pageInfo"`
+ TotalCount int `json:"totalCount"`
+ }
+ require.NoError(t, json.Unmarshal([]byte(text), &response))
+ assert.Equal(t, tc.expectedCategories, response.Categories)
+ })
+ }
}
diff --git a/pkg/github/gists.go b/pkg/github/gists.go
new file mode 100644
index 000000000..3f1645f3e
--- /dev/null
+++ b/pkg/github/gists.go
@@ -0,0 +1,267 @@
+package github
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+
+ "github.com/github/github-mcp-server/pkg/translations"
+ "github.com/google/go-github/v74/github"
+ "github.com/mark3labs/mcp-go/mcp"
+ "github.com/mark3labs/mcp-go/server"
+)
+
+// ListGists creates a tool to list gists for a user
+func ListGists(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
+ return mcp.NewTool("list_gists",
+ mcp.WithDescription(t("TOOL_LIST_GISTS_DESCRIPTION", "List gists for a user")),
+ mcp.WithToolAnnotation(mcp.ToolAnnotation{
+ Title: t("TOOL_LIST_GISTS", "List Gists"),
+ ReadOnlyHint: ToBoolPtr(true),
+ }),
+ mcp.WithString("username",
+ mcp.Description("GitHub username (omit for authenticated user's gists)"),
+ ),
+ mcp.WithString("since",
+ mcp.Description("Only gists updated after this time (ISO 8601 timestamp)"),
+ ),
+ WithPagination(),
+ ),
+ func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
+ username, err := OptionalParam[string](request, "username")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+
+ since, err := OptionalParam[string](request, "since")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+
+ pagination, err := OptionalPaginationParams(request)
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+
+ opts := &github.GistListOptions{
+ ListOptions: github.ListOptions{
+ Page: pagination.Page,
+ PerPage: pagination.PerPage,
+ },
+ }
+
+ // Parse since timestamp if provided
+ if since != "" {
+ sinceTime, err := parseISOTimestamp(since)
+ if err != nil {
+ return mcp.NewToolResultError(fmt.Sprintf("invalid since timestamp: %v", err)), nil
+ }
+ opts.Since = sinceTime
+ }
+
+ client, err := getClient(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get GitHub client: %w", err)
+ }
+
+ gists, resp, err := client.Gists.List(ctx, username, opts)
+ if err != nil {
+ return nil, fmt.Errorf("failed to list gists: %w", err)
+ }
+ defer func() { _ = resp.Body.Close() }()
+
+ if resp.StatusCode != http.StatusOK {
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, fmt.Errorf("failed to read response body: %w", err)
+ }
+ return 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
+ }
+}
+
+// CreateGist creates a tool to create a new gist
+func CreateGist(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
+ return mcp.NewTool("create_gist",
+ mcp.WithDescription(t("TOOL_CREATE_GIST_DESCRIPTION", "Create a new gist")),
+ mcp.WithToolAnnotation(mcp.ToolAnnotation{
+ Title: t("TOOL_CREATE_GIST", "Create Gist"),
+ ReadOnlyHint: ToBoolPtr(false),
+ }),
+ mcp.WithString("description",
+ mcp.Description("Description of the gist"),
+ ),
+ mcp.WithString("filename",
+ mcp.Required(),
+ mcp.Description("Filename for simple single-file gist creation"),
+ ),
+ mcp.WithString("content",
+ mcp.Required(),
+ mcp.Description("Content for simple single-file gist creation"),
+ ),
+ mcp.WithBoolean("public",
+ mcp.Description("Whether the gist is public"),
+ mcp.DefaultBool(false),
+ ),
+ ),
+ func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
+ description, err := OptionalParam[string](request, "description")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+
+ filename, err := RequiredParam[string](request, "filename")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+
+ content, err := RequiredParam[string](request, "content")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+
+ public, err := OptionalParam[bool](request, "public")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+
+ files := make(map[github.GistFilename]github.GistFile)
+ files[github.GistFilename(filename)] = github.GistFile{
+ Filename: github.Ptr(filename),
+ Content: github.Ptr(content),
+ }
+
+ gist := &github.Gist{
+ Files: files,
+ Public: github.Ptr(public),
+ Description: github.Ptr(description),
+ }
+
+ client, err := getClient(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get GitHub client: %w", err)
+ }
+
+ createdGist, resp, err := client.Gists.Create(ctx, gist)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create gist: %w", err)
+ }
+ defer func() { _ = resp.Body.Close() }()
+
+ if resp.StatusCode != http.StatusCreated {
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, fmt.Errorf("failed to read response body: %w", err)
+ }
+ return mcp.NewToolResultError(fmt.Sprintf("failed to create gist: %s", string(body))), nil
+ }
+
+ minimalResponse := MinimalResponse{
+ URL: createdGist.GetHTMLURL(),
+ }
+
+ r, err := json.Marshal(minimalResponse)
+ if err != nil {
+ return nil, fmt.Errorf("failed to marshal response: %w", err)
+ }
+
+ return mcp.NewToolResultText(string(r)), nil
+ }
+}
+
+// UpdateGist creates a tool to edit an existing gist
+func UpdateGist(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
+ return mcp.NewTool("update_gist",
+ mcp.WithDescription(t("TOOL_UPDATE_GIST_DESCRIPTION", "Update an existing gist")),
+ mcp.WithToolAnnotation(mcp.ToolAnnotation{
+ Title: t("TOOL_UPDATE_GIST", "Update Gist"),
+ ReadOnlyHint: ToBoolPtr(false),
+ }),
+ mcp.WithString("gist_id",
+ mcp.Required(),
+ mcp.Description("ID of the gist to update"),
+ ),
+ mcp.WithString("description",
+ mcp.Description("Updated description of the gist"),
+ ),
+ mcp.WithString("filename",
+ mcp.Required(),
+ mcp.Description("Filename to update or create"),
+ ),
+ mcp.WithString("content",
+ mcp.Required(),
+ mcp.Description("Content for the file"),
+ ),
+ ),
+ func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
+ gistID, err := RequiredParam[string](request, "gist_id")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+
+ description, err := OptionalParam[string](request, "description")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+
+ filename, err := RequiredParam[string](request, "filename")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+
+ content, err := RequiredParam[string](request, "content")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+
+ files := make(map[github.GistFilename]github.GistFile)
+ files[github.GistFilename(filename)] = github.GistFile{
+ Filename: github.Ptr(filename),
+ Content: github.Ptr(content),
+ }
+
+ gist := &github.Gist{
+ Files: files,
+ Description: github.Ptr(description),
+ }
+
+ client, err := getClient(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get GitHub client: %w", err)
+ }
+
+ updatedGist, resp, err := client.Gists.Edit(ctx, gistID, gist)
+ if err != nil {
+ return nil, fmt.Errorf("failed to update gist: %w", err)
+ }
+ defer func() { _ = resp.Body.Close() }()
+
+ if resp.StatusCode != http.StatusOK {
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, fmt.Errorf("failed to read response body: %w", err)
+ }
+ return mcp.NewToolResultError(fmt.Sprintf("failed to update gist: %s", string(body))), nil
+ }
+
+ minimalResponse := MinimalResponse{
+ URL: updatedGist.GetHTMLURL(),
+ }
+
+ r, err := json.Marshal(minimalResponse)
+ if err != nil {
+ return nil, fmt.Errorf("failed to marshal response: %w", err)
+ }
+
+ return mcp.NewToolResultText(string(r)), nil
+ }
+}
diff --git a/pkg/github/gists_test.go b/pkg/github/gists_test.go
new file mode 100644
index 000000000..9b8b4eb6e
--- /dev/null
+++ b/pkg/github/gists_test.go
@@ -0,0 +1,486 @@
+package github
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "testing"
+ "time"
+
+ "github.com/github/github-mcp-server/pkg/translations"
+ "github.com/google/go-github/v74/github"
+ "github.com/migueleliasweb/go-github-mock/src/mock"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func Test_ListGists(t *testing.T) {
+ // Verify tool definition
+ mockClient := github.NewClient(nil)
+ tool, _ := ListGists(stubGetClientFn(mockClient), translations.NullTranslationHelper)
+
+ assert.Equal(t, "list_gists", tool.Name)
+ 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.Empty(t, tool.InputSchema.Required)
+
+ // Setup mock gists for success case
+ mockGists := []*github.Gist{
+ {
+ ID: github.Ptr("gist1"),
+ Description: github.Ptr("First Gist"),
+ HTMLURL: github.Ptr("https://gist.github.com/user/gist1"),
+ Public: github.Ptr(true),
+ CreatedAt: &github.Timestamp{Time: time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC)},
+ Owner: &github.User{Login: github.Ptr("user")},
+ Files: map[github.GistFilename]github.GistFile{
+ "file1.txt": {
+ Filename: github.Ptr("file1.txt"),
+ Content: github.Ptr("content of file 1"),
+ },
+ },
+ },
+ {
+ ID: github.Ptr("gist2"),
+ Description: github.Ptr("Second Gist"),
+ HTMLURL: github.Ptr("https://gist.github.com/testuser/gist2"),
+ Public: github.Ptr(false),
+ CreatedAt: &github.Timestamp{Time: time.Date(2023, 2, 1, 0, 0, 0, 0, time.UTC)},
+ Owner: &github.User{Login: github.Ptr("testuser")},
+ Files: map[github.GistFilename]github.GistFile{
+ "file2.js": {
+ Filename: github.Ptr("file2.js"),
+ Content: github.Ptr("console.log('hello');"),
+ },
+ },
+ },
+ }
+
+ tests := []struct {
+ name string
+ mockedClient *http.Client
+ requestArgs map[string]interface{}
+ expectError bool
+ expectedGists []*github.Gist
+ expectedErrMsg string
+ }{
+ {
+ name: "list authenticated user's gists",
+ mockedClient: mock.NewMockedHTTPClient(
+ mock.WithRequestMatch(
+ mock.GetGists,
+ mockGists,
+ ),
+ ),
+ requestArgs: map[string]interface{}{},
+ expectError: false,
+ expectedGists: mockGists,
+ },
+ {
+ name: "list specific user's gists",
+ mockedClient: mock.NewMockedHTTPClient(
+ mock.WithRequestMatchHandler(
+ mock.GetUsersGistsByUsername,
+ mockResponse(t, http.StatusOK, mockGists),
+ ),
+ ),
+ requestArgs: map[string]interface{}{
+ "username": "testuser",
+ },
+ expectError: false,
+ expectedGists: mockGists,
+ },
+ {
+ name: "list gists with pagination and since parameter",
+ mockedClient: mock.NewMockedHTTPClient(
+ mock.WithRequestMatchHandler(
+ mock.GetGists,
+ expectQueryParams(t, map[string]string{
+ "since": "2023-01-01T00:00:00Z",
+ "page": "2",
+ "per_page": "5",
+ }).andThen(
+ mockResponse(t, http.StatusOK, mockGists),
+ ),
+ ),
+ ),
+ requestArgs: map[string]interface{}{
+ "since": "2023-01-01T00:00:00Z",
+ "page": float64(2),
+ "perPage": float64(5),
+ },
+ expectError: false,
+ expectedGists: mockGists,
+ },
+ {
+ name: "invalid since parameter",
+ mockedClient: mock.NewMockedHTTPClient(
+ mock.WithRequestMatch(
+ mock.GetGists,
+ mockGists,
+ ),
+ ),
+ requestArgs: map[string]interface{}{
+ "since": "invalid-date",
+ },
+ expectError: true,
+ expectedErrMsg: "invalid since timestamp",
+ },
+ {
+ name: "list gists fails with error",
+ mockedClient: mock.NewMockedHTTPClient(
+ mock.WithRequestMatchHandler(
+ mock.GetGists,
+ http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(http.StatusUnauthorized)
+ _, _ = w.Write([]byte(`{"message": "Requires authentication"}`))
+ }),
+ ),
+ ),
+ requestArgs: map[string]interface{}{},
+ expectError: true,
+ expectedErrMsg: "failed to list gists",
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ // Setup client with mock
+ client := github.NewClient(tc.mockedClient)
+ _, handler := ListGists(stubGetClientFn(client), translations.NullTranslationHelper)
+
+ // Create call request
+ request := createMCPRequest(tc.requestArgs)
+
+ // Call handler
+ result, err := handler(context.Background(), request)
+
+ // Verify results
+ if tc.expectError {
+ if err != nil {
+ assert.Contains(t, err.Error(), tc.expectedErrMsg)
+ } else {
+ // For errors returned as part of the result, not as an error
+ assert.NotNil(t, result)
+ textContent := getTextResult(t, result)
+ assert.Contains(t, textContent.Text, tc.expectedErrMsg)
+ }
+ return
+ }
+
+ require.NoError(t, err)
+
+ // Parse the result and get the text content if no error
+ textContent := getTextResult(t, result)
+
+ // Unmarshal and verify the result
+ var returnedGists []*github.Gist
+ err = json.Unmarshal([]byte(textContent.Text), &returnedGists)
+ require.NoError(t, err)
+
+ assert.Len(t, returnedGists, len(tc.expectedGists))
+ for i, gist := range returnedGists {
+ assert.Equal(t, *tc.expectedGists[i].ID, *gist.ID)
+ assert.Equal(t, *tc.expectedGists[i].Description, *gist.Description)
+ assert.Equal(t, *tc.expectedGists[i].HTMLURL, *gist.HTMLURL)
+ assert.Equal(t, *tc.expectedGists[i].Public, *gist.Public)
+ }
+ })
+ }
+}
+
+func Test_CreateGist(t *testing.T) {
+ // Verify tool definition
+ mockClient := github.NewClient(nil)
+ tool, _ := CreateGist(stubGetClientFn(mockClient), translations.NullTranslationHelper)
+
+ assert.Equal(t, "create_gist", tool.Name)
+ assert.NotEmpty(t, tool.Description)
+ assert.Contains(t, tool.InputSchema.Properties, "description")
+ assert.Contains(t, tool.InputSchema.Properties, "filename")
+ assert.Contains(t, tool.InputSchema.Properties, "content")
+ assert.Contains(t, tool.InputSchema.Properties, "public")
+
+ // Verify required parameters
+ assert.Contains(t, tool.InputSchema.Required, "filename")
+ assert.Contains(t, tool.InputSchema.Required, "content")
+
+ // Setup mock data for test cases
+ createdGist := &github.Gist{
+ ID: github.Ptr("new-gist-id"),
+ Description: github.Ptr("Test Gist"),
+ HTMLURL: github.Ptr("https://gist.github.com/user/new-gist-id"),
+ Public: github.Ptr(false),
+ CreatedAt: &github.Timestamp{Time: time.Now()},
+ Owner: &github.User{Login: github.Ptr("user")},
+ Files: map[github.GistFilename]github.GistFile{
+ "test.go": {
+ Filename: github.Ptr("test.go"),
+ Content: github.Ptr("package main\n\nfunc main() {\n\tfmt.Println(\"Hello, Gist!\")\n}"),
+ },
+ },
+ }
+
+ tests := []struct {
+ name string
+ mockedClient *http.Client
+ requestArgs map[string]interface{}
+ expectError bool
+ expectedErrMsg string
+ expectedGist *github.Gist
+ }{
+ {
+ name: "create gist successfully",
+ mockedClient: mock.NewMockedHTTPClient(
+ mock.WithRequestMatchHandler(
+ mock.PostGists,
+ mockResponse(t, http.StatusCreated, createdGist),
+ ),
+ ),
+ requestArgs: map[string]interface{}{
+ "filename": "test.go",
+ "content": "package main\n\nfunc main() {\n\tfmt.Println(\"Hello, Gist!\")\n}",
+ "description": "Test Gist",
+ "public": false,
+ },
+ expectError: false,
+ expectedGist: createdGist,
+ },
+ {
+ name: "missing required filename",
+ mockedClient: mock.NewMockedHTTPClient(),
+ requestArgs: map[string]interface{}{
+ "content": "test content",
+ "description": "Test Gist",
+ },
+ expectError: true,
+ expectedErrMsg: "missing required parameter: filename",
+ },
+ {
+ name: "missing required content",
+ mockedClient: mock.NewMockedHTTPClient(),
+ requestArgs: map[string]interface{}{
+ "filename": "test.go",
+ "description": "Test Gist",
+ },
+ expectError: true,
+ expectedErrMsg: "missing required parameter: content",
+ },
+ {
+ name: "api returns error",
+ mockedClient: mock.NewMockedHTTPClient(
+ mock.WithRequestMatchHandler(
+ mock.PostGists,
+ http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(http.StatusUnauthorized)
+ _, _ = w.Write([]byte(`{"message": "Requires authentication"}`))
+ }),
+ ),
+ ),
+ requestArgs: map[string]interface{}{
+ "filename": "test.go",
+ "content": "package main",
+ "description": "Test Gist",
+ },
+ expectError: true,
+ expectedErrMsg: "failed to create gist",
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ // Setup client with mock
+ client := github.NewClient(tc.mockedClient)
+ _, handler := CreateGist(stubGetClientFn(client), translations.NullTranslationHelper)
+
+ // Create call request
+ request := createMCPRequest(tc.requestArgs)
+
+ // Call handler
+ result, err := handler(context.Background(), request)
+
+ // Verify results
+ if tc.expectError {
+ if err != nil {
+ assert.Contains(t, err.Error(), tc.expectedErrMsg)
+ } else {
+ // For errors returned as part of the result, not as an error
+ assert.NotNil(t, result)
+ textContent := getTextResult(t, result)
+ assert.Contains(t, textContent.Text, tc.expectedErrMsg)
+ }
+ return
+ }
+
+ require.NoError(t, err)
+ assert.NotNil(t, result)
+
+ // Parse the result and get the text content
+ textContent := getTextResult(t, result)
+
+ // Unmarshal and verify the minimal result
+ var gist MinimalResponse
+ err = json.Unmarshal([]byte(textContent.Text), &gist)
+ require.NoError(t, err)
+
+ assert.Equal(t, tc.expectedGist.GetHTMLURL(), gist.URL)
+ })
+ }
+}
+
+func Test_UpdateGist(t *testing.T) {
+ // Verify tool definition
+ mockClient := github.NewClient(nil)
+ tool, _ := UpdateGist(stubGetClientFn(mockClient), translations.NullTranslationHelper)
+
+ assert.Equal(t, "update_gist", tool.Name)
+ assert.NotEmpty(t, tool.Description)
+ assert.Contains(t, tool.InputSchema.Properties, "gist_id")
+ assert.Contains(t, tool.InputSchema.Properties, "description")
+ assert.Contains(t, tool.InputSchema.Properties, "filename")
+ assert.Contains(t, tool.InputSchema.Properties, "content")
+
+ // Verify required parameters
+ assert.Contains(t, tool.InputSchema.Required, "gist_id")
+ assert.Contains(t, tool.InputSchema.Required, "filename")
+ assert.Contains(t, tool.InputSchema.Required, "content")
+
+ // Setup mock data for test cases
+ updatedGist := &github.Gist{
+ ID: github.Ptr("existing-gist-id"),
+ Description: github.Ptr("Updated Test Gist"),
+ HTMLURL: github.Ptr("https://gist.github.com/user/existing-gist-id"),
+ Public: github.Ptr(true),
+ UpdatedAt: &github.Timestamp{Time: time.Now()},
+ Owner: &github.User{Login: github.Ptr("user")},
+ Files: map[github.GistFilename]github.GistFile{
+ "updated.go": {
+ Filename: github.Ptr("updated.go"),
+ Content: github.Ptr("package main\n\nfunc main() {\n\tfmt.Println(\"Updated Gist!\")\n}"),
+ },
+ },
+ }
+
+ tests := []struct {
+ name string
+ mockedClient *http.Client
+ requestArgs map[string]interface{}
+ expectError bool
+ expectedErrMsg string
+ expectedGist *github.Gist
+ }{
+ {
+ name: "update gist successfully",
+ mockedClient: mock.NewMockedHTTPClient(
+ mock.WithRequestMatchHandler(
+ mock.PatchGistsByGistId,
+ mockResponse(t, http.StatusOK, updatedGist),
+ ),
+ ),
+ requestArgs: map[string]interface{}{
+ "gist_id": "existing-gist-id",
+ "filename": "updated.go",
+ "content": "package main\n\nfunc main() {\n\tfmt.Println(\"Updated Gist!\")\n}",
+ "description": "Updated Test Gist",
+ },
+ expectError: false,
+ expectedGist: updatedGist,
+ },
+ {
+ name: "missing required gist_id",
+ mockedClient: mock.NewMockedHTTPClient(),
+ requestArgs: map[string]interface{}{
+ "filename": "updated.go",
+ "content": "updated content",
+ "description": "Updated Test Gist",
+ },
+ expectError: true,
+ expectedErrMsg: "missing required parameter: gist_id",
+ },
+ {
+ name: "missing required filename",
+ mockedClient: mock.NewMockedHTTPClient(),
+ requestArgs: map[string]interface{}{
+ "gist_id": "existing-gist-id",
+ "content": "updated content",
+ "description": "Updated Test Gist",
+ },
+ expectError: true,
+ expectedErrMsg: "missing required parameter: filename",
+ },
+ {
+ name: "missing required content",
+ mockedClient: mock.NewMockedHTTPClient(),
+ requestArgs: map[string]interface{}{
+ "gist_id": "existing-gist-id",
+ "filename": "updated.go",
+ "description": "Updated Test Gist",
+ },
+ expectError: true,
+ expectedErrMsg: "missing required parameter: content",
+ },
+ {
+ name: "api returns error",
+ mockedClient: mock.NewMockedHTTPClient(
+ mock.WithRequestMatchHandler(
+ mock.PatchGistsByGistId,
+ http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(http.StatusNotFound)
+ _, _ = w.Write([]byte(`{"message": "Not Found"}`))
+ }),
+ ),
+ ),
+ requestArgs: map[string]interface{}{
+ "gist_id": "nonexistent-gist-id",
+ "filename": "updated.go",
+ "content": "package main",
+ "description": "Updated Test Gist",
+ },
+ expectError: true,
+ expectedErrMsg: "failed to update gist",
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ // Setup client with mock
+ client := github.NewClient(tc.mockedClient)
+ _, handler := UpdateGist(stubGetClientFn(client), translations.NullTranslationHelper)
+
+ // Create call request
+ request := createMCPRequest(tc.requestArgs)
+
+ // Call handler
+ result, err := handler(context.Background(), request)
+
+ // Verify results
+ if tc.expectError {
+ if err != nil {
+ assert.Contains(t, err.Error(), tc.expectedErrMsg)
+ } else {
+ // For errors returned as part of the result, not as an error
+ assert.NotNil(t, result)
+ textContent := getTextResult(t, result)
+ assert.Contains(t, textContent.Text, tc.expectedErrMsg)
+ }
+ return
+ }
+
+ require.NoError(t, err)
+ assert.NotNil(t, result)
+
+ // Parse the result and get the text content
+ textContent := getTextResult(t, result)
+
+ // Unmarshal and verify the minimal result
+ var updateResp MinimalResponse
+ err = json.Unmarshal([]byte(textContent.Text), &updateResp)
+ require.NoError(t, err)
+
+ assert.Equal(t, tc.expectedGist.GetHTMLURL(), updateResp.URL)
+ })
+ }
+}
diff --git a/pkg/github/issues.go b/pkg/github/issues.go
index 29d32bd18..01ce7b42e 100644
--- a/pkg/github/issues.go
+++ b/pkg/github/issues.go
@@ -9,14 +9,141 @@ import (
"strings"
"time"
+ ghErrors "github.com/github/github-mcp-server/pkg/errors"
"github.com/github/github-mcp-server/pkg/translations"
"github.com/go-viper/mapstructure/v2"
- "github.com/google/go-github/v73/github"
+ "github.com/google/go-github/v74/github"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
"github.com/shurcooL/githubv4"
)
+// IssueFragment represents a fragment of an issue node in the GraphQL API.
+type IssueFragment struct {
+ Number githubv4.Int
+ Title githubv4.String
+ Body githubv4.String
+ State githubv4.String
+ DatabaseID int64
+
+ Author struct {
+ Login githubv4.String
+ }
+ CreatedAt githubv4.DateTime
+ UpdatedAt githubv4.DateTime
+ Labels struct {
+ Nodes []struct {
+ Name githubv4.String
+ ID githubv4.String
+ Description githubv4.String
+ }
+ } `graphql:"labels(first: 100)"`
+ Comments struct {
+ TotalCount githubv4.Int
+ } `graphql:"comments"`
+}
+
+// Common interface for all issue query types
+type IssueQueryResult interface {
+ GetIssueFragment() IssueQueryFragment
+}
+
+type IssueQueryFragment struct {
+ Nodes []IssueFragment `graphql:"nodes"`
+ PageInfo struct {
+ HasNextPage githubv4.Boolean
+ HasPreviousPage githubv4.Boolean
+ StartCursor githubv4.String
+ EndCursor githubv4.String
+ }
+ TotalCount int
+}
+
+// ListIssuesQuery is the root query structure for fetching issues with optional label filtering.
+type ListIssuesQuery struct {
+ Repository struct {
+ Issues IssueQueryFragment `graphql:"issues(first: $first, after: $after, states: $states, orderBy: {field: $orderBy, direction: $direction})"`
+ } `graphql:"repository(owner: $owner, name: $repo)"`
+}
+
+// ListIssuesQueryTypeWithLabels is the query structure for fetching issues with optional label filtering.
+type ListIssuesQueryTypeWithLabels struct {
+ Repository struct {
+ Issues IssueQueryFragment `graphql:"issues(first: $first, after: $after, labels: $labels, states: $states, orderBy: {field: $orderBy, direction: $direction})"`
+ } `graphql:"repository(owner: $owner, name: $repo)"`
+}
+
+// ListIssuesQueryWithSince is the query structure for fetching issues without label filtering but with since filtering.
+type ListIssuesQueryWithSince struct {
+ Repository struct {
+ Issues IssueQueryFragment `graphql:"issues(first: $first, after: $after, states: $states, orderBy: {field: $orderBy, direction: $direction}, filterBy: {since: $since})"`
+ } `graphql:"repository(owner: $owner, name: $repo)"`
+}
+
+// ListIssuesQueryTypeWithLabelsWithSince is the query structure for fetching issues with both label and since filtering.
+type ListIssuesQueryTypeWithLabelsWithSince struct {
+ Repository struct {
+ Issues IssueQueryFragment `graphql:"issues(first: $first, after: $after, labels: $labels, states: $states, orderBy: {field: $orderBy, direction: $direction}, filterBy: {since: $since})"`
+ } `graphql:"repository(owner: $owner, name: $repo)"`
+}
+
+// Implement the interface for all query types
+func (q *ListIssuesQueryTypeWithLabels) GetIssueFragment() IssueQueryFragment {
+ return q.Repository.Issues
+}
+
+func (q *ListIssuesQuery) GetIssueFragment() IssueQueryFragment {
+ return q.Repository.Issues
+}
+
+func (q *ListIssuesQueryWithSince) GetIssueFragment() IssueQueryFragment {
+ return q.Repository.Issues
+}
+
+func (q *ListIssuesQueryTypeWithLabelsWithSince) GetIssueFragment() IssueQueryFragment {
+ return q.Repository.Issues
+}
+
+func getIssueQueryType(hasLabels bool, hasSince bool) any {
+ switch {
+ case hasLabels && hasSince:
+ return &ListIssuesQueryTypeWithLabelsWithSince{}
+ case hasLabels:
+ return &ListIssuesQueryTypeWithLabels{}
+ case hasSince:
+ return &ListIssuesQueryWithSince{}
+ default:
+ return &ListIssuesQuery{}
+ }
+}
+
+func fragmentToIssue(fragment IssueFragment) *github.Issue {
+ // Convert GraphQL labels to GitHub API labels format
+ var foundLabels []*github.Label
+ for _, labelNode := range fragment.Labels.Nodes {
+ foundLabels = append(foundLabels, &github.Label{
+ Name: github.Ptr(string(labelNode.Name)),
+ NodeID: github.Ptr(string(labelNode.ID)),
+ Description: github.Ptr(string(labelNode.Description)),
+ })
+ }
+
+ return &github.Issue{
+ Number: github.Ptr(int(fragment.Number)),
+ Title: github.Ptr(string(fragment.Title)),
+ CreatedAt: &github.Timestamp{Time: fragment.CreatedAt.Time},
+ UpdatedAt: &github.Timestamp{Time: fragment.UpdatedAt.Time},
+ User: &github.User{
+ Login: github.Ptr(string(fragment.Author.Login)),
+ },
+ State: github.Ptr(string(fragment.State)),
+ ID: github.Ptr(fragment.DatabaseID),
+ Body: github.Ptr(string(fragment.Body)),
+ Labels: foundLabels,
+ Comments: github.Ptr(int(fragment.Comments.TotalCount)),
+ }
+}
+
// GetIssue creates a tool to get details of a specific issue in a GitHub repository.
func GetIssue(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
return mcp.NewTool("get_issue",
@@ -79,6 +206,53 @@ func GetIssue(getClient GetClientFn, t translations.TranslationHelperFunc) (tool
}
}
+// ListIssueTypes creates a tool to list defined issue types for an organization. This can be used to understand supported issue type values for creating or updating issues.
+func ListIssueTypes(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
+
+ return mcp.NewTool("list_issue_types",
+ mcp.WithDescription(t("TOOL_LIST_ISSUE_TYPES_FOR_ORG", "List supported issue types for repository owner (organization).")),
+ mcp.WithToolAnnotation(mcp.ToolAnnotation{
+ Title: t("TOOL_LIST_ISSUE_TYPES_USER_TITLE", "List available issue types"),
+ ReadOnlyHint: ToBoolPtr(true),
+ }),
+ mcp.WithString("owner",
+ mcp.Required(),
+ mcp.Description("The organization owner of the repository"),
+ ),
+ ),
+ func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
+ owner, err := RequiredParam[string](request, "owner")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+
+ client, err := getClient(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get GitHub client: %w", err)
+ }
+ issueTypes, resp, err := client.Organizations.ListIssueTypes(ctx, owner)
+ if err != nil {
+ return nil, fmt.Errorf("failed to list issue types: %w", err)
+ }
+ defer func() { _ = resp.Body.Close() }()
+
+ if resp.StatusCode != http.StatusOK {
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, fmt.Errorf("failed to read response body: %w", err)
+ }
+ return mcp.NewToolResultError(fmt.Sprintf("failed to list issue types: %s", string(body))), nil
+ }
+
+ r, err := json.Marshal(issueTypes)
+ if err != nil {
+ return nil, fmt.Errorf("failed to marshal issue types: %w", err)
+ }
+
+ return mcp.NewToolResultText(string(r)), nil
+ }
+}
+
// AddIssueComment creates a tool to add a comment to an issue.
func AddIssueComment(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
return mcp.NewTool("add_issue_comment",
@@ -153,6 +327,380 @@ func AddIssueComment(getClient GetClientFn, t translations.TranslationHelperFunc
}
}
+// AddSubIssue creates a tool to add a sub-issue to a parent issue.
+func AddSubIssue(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
+ return mcp.NewTool("add_sub_issue",
+ mcp.WithDescription(t("TOOL_ADD_SUB_ISSUE_DESCRIPTION", "Add a sub-issue to a parent issue in a GitHub repository.")),
+ mcp.WithToolAnnotation(mcp.ToolAnnotation{
+ Title: t("TOOL_ADD_SUB_ISSUE_USER_TITLE", "Add sub-issue"),
+ ReadOnlyHint: ToBoolPtr(false),
+ }),
+ mcp.WithString("owner",
+ mcp.Required(),
+ mcp.Description("Repository owner"),
+ ),
+ mcp.WithString("repo",
+ mcp.Required(),
+ mcp.Description("Repository name"),
+ ),
+ mcp.WithNumber("issue_number",
+ mcp.Required(),
+ mcp.Description("The number of the parent issue"),
+ ),
+ mcp.WithNumber("sub_issue_id",
+ mcp.Required(),
+ mcp.Description("The ID of the sub-issue to add. ID is not the same as issue number"),
+ ),
+ mcp.WithBoolean("replace_parent",
+ mcp.Description("When true, replaces the sub-issue's current parent issue"),
+ ),
+ ),
+ func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
+ owner, err := RequiredParam[string](request, "owner")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+ repo, err := RequiredParam[string](request, "repo")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+ issueNumber, err := RequiredInt(request, "issue_number")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+ subIssueID, err := RequiredInt(request, "sub_issue_id")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+ replaceParent, err := OptionalParam[bool](request, "replace_parent")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+
+ client, err := getClient(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get GitHub client: %w", err)
+ }
+
+ subIssueRequest := github.SubIssueRequest{
+ SubIssueID: int64(subIssueID),
+ ReplaceParent: ToBoolPtr(replaceParent),
+ }
+
+ subIssue, resp, err := client.SubIssue.Add(ctx, owner, repo, int64(issueNumber), subIssueRequest)
+ if err != nil {
+ return ghErrors.NewGitHubAPIErrorResponse(ctx,
+ "failed to add sub-issue",
+ resp,
+ err,
+ ), nil
+ }
+
+ defer func() { _ = resp.Body.Close() }()
+
+ if resp.StatusCode != http.StatusCreated {
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, fmt.Errorf("failed to read response body: %w", err)
+ }
+ return mcp.NewToolResultError(fmt.Sprintf("failed to add sub-issue: %s", string(body))), nil
+ }
+
+ r, err := json.Marshal(subIssue)
+ if err != nil {
+ return nil, fmt.Errorf("failed to marshal response: %w", err)
+ }
+
+ return mcp.NewToolResultText(string(r)), nil
+ }
+}
+
+// ListSubIssues creates a tool to list sub-issues for a GitHub issue.
+func ListSubIssues(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
+ return mcp.NewTool("list_sub_issues",
+ mcp.WithDescription(t("TOOL_LIST_SUB_ISSUES_DESCRIPTION", "List sub-issues for a specific issue in a GitHub repository.")),
+ mcp.WithToolAnnotation(mcp.ToolAnnotation{
+ Title: t("TOOL_LIST_SUB_ISSUES_USER_TITLE", "List sub-issues"),
+ ReadOnlyHint: ToBoolPtr(true),
+ }),
+ mcp.WithString("owner",
+ mcp.Required(),
+ mcp.Description("Repository owner"),
+ ),
+ mcp.WithString("repo",
+ mcp.Required(),
+ mcp.Description("Repository name"),
+ ),
+ mcp.WithNumber("issue_number",
+ mcp.Required(),
+ mcp.Description("Issue number"),
+ ),
+ mcp.WithNumber("page",
+ mcp.Description("Page number for pagination (default: 1)"),
+ ),
+ mcp.WithNumber("per_page",
+ mcp.Description("Number of results per page (max 100, default: 30)"),
+ ),
+ ),
+ func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
+ owner, err := RequiredParam[string](request, "owner")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+ repo, err := RequiredParam[string](request, "repo")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+ issueNumber, err := RequiredInt(request, "issue_number")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+ page, err := OptionalIntParamWithDefault(request, "page", 1)
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+ perPage, err := OptionalIntParamWithDefault(request, "per_page", 30)
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+
+ client, err := getClient(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get GitHub client: %w", err)
+ }
+
+ opts := &github.IssueListOptions{
+ ListOptions: github.ListOptions{
+ Page: page,
+ PerPage: perPage,
+ },
+ }
+
+ subIssues, resp, err := client.SubIssue.ListByIssue(ctx, owner, repo, int64(issueNumber), opts)
+ if err != nil {
+ return ghErrors.NewGitHubAPIErrorResponse(ctx,
+ "failed to list sub-issues",
+ resp,
+ err,
+ ), nil
+ }
+
+ defer func() { _ = resp.Body.Close() }()
+
+ if resp.StatusCode != http.StatusOK {
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, fmt.Errorf("failed to read response body: %w", err)
+ }
+ return 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
+ }
+
+}
+
+// RemoveSubIssue creates a tool to remove a sub-issue from a parent issue.
+// Unlike other sub-issue tools, this currently uses a direct HTTP DELETE request
+// because of a bug in the go-github library.
+// Once the fix is released, this can be updated to use the library method.
+// See: https://github.com/google/go-github/pull/3613
+func RemoveSubIssue(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
+ return mcp.NewTool("remove_sub_issue",
+ mcp.WithDescription(t("TOOL_REMOVE_SUB_ISSUE_DESCRIPTION", "Remove a sub-issue from a parent issue in a GitHub repository.")),
+ mcp.WithToolAnnotation(mcp.ToolAnnotation{
+ Title: t("TOOL_REMOVE_SUB_ISSUE_USER_TITLE", "Remove sub-issue"),
+ ReadOnlyHint: ToBoolPtr(false),
+ }),
+ mcp.WithString("owner",
+ mcp.Required(),
+ mcp.Description("Repository owner"),
+ ),
+ mcp.WithString("repo",
+ mcp.Required(),
+ mcp.Description("Repository name"),
+ ),
+ mcp.WithNumber("issue_number",
+ mcp.Required(),
+ mcp.Description("The number of the parent issue"),
+ ),
+ mcp.WithNumber("sub_issue_id",
+ mcp.Required(),
+ mcp.Description("The ID of the sub-issue to remove. ID is not the same as issue number"),
+ ),
+ ),
+ func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
+ owner, err := RequiredParam[string](request, "owner")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+ repo, err := RequiredParam[string](request, "repo")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+ issueNumber, err := RequiredInt(request, "issue_number")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+ subIssueID, err := RequiredInt(request, "sub_issue_id")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+
+ client, err := getClient(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get GitHub client: %w", err)
+ }
+
+ subIssueRequest := github.SubIssueRequest{
+ SubIssueID: int64(subIssueID),
+ }
+
+ subIssue, resp, err := client.SubIssue.Remove(ctx, owner, repo, int64(issueNumber), subIssueRequest)
+ if err != nil {
+ return ghErrors.NewGitHubAPIErrorResponse(ctx,
+ "failed to remove sub-issue",
+ resp,
+ err,
+ ), nil
+ }
+ defer func() { _ = resp.Body.Close() }()
+
+ if resp.StatusCode != http.StatusOK {
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, fmt.Errorf("failed to read response body: %w", err)
+ }
+ return mcp.NewToolResultError(fmt.Sprintf("failed to remove sub-issue: %s", string(body))), nil
+ }
+
+ r, err := json.Marshal(subIssue)
+ if err != nil {
+ return nil, fmt.Errorf("failed to marshal response: %w", err)
+ }
+
+ return mcp.NewToolResultText(string(r)), nil
+ }
+}
+
+// ReprioritizeSubIssue creates a tool to reprioritize a sub-issue to a different position in the parent list.
+func ReprioritizeSubIssue(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
+ return mcp.NewTool("reprioritize_sub_issue",
+ mcp.WithDescription(t("TOOL_REPRIORITIZE_SUB_ISSUE_DESCRIPTION", "Reprioritize a sub-issue to a different position in the parent issue's sub-issue list.")),
+ mcp.WithToolAnnotation(mcp.ToolAnnotation{
+ Title: t("TOOL_REPRIORITIZE_SUB_ISSUE_USER_TITLE", "Reprioritize sub-issue"),
+ ReadOnlyHint: ToBoolPtr(false),
+ }),
+ mcp.WithString("owner",
+ mcp.Required(),
+ mcp.Description("Repository owner"),
+ ),
+ mcp.WithString("repo",
+ mcp.Required(),
+ mcp.Description("Repository name"),
+ ),
+ mcp.WithNumber("issue_number",
+ mcp.Required(),
+ mcp.Description("The number of the parent issue"),
+ ),
+ mcp.WithNumber("sub_issue_id",
+ mcp.Required(),
+ mcp.Description("The ID of the sub-issue to reprioritize. ID is not the same as issue number"),
+ ),
+ mcp.WithNumber("after_id",
+ mcp.Description("The ID of the sub-issue to be prioritized after (either after_id OR before_id should be specified)"),
+ ),
+ mcp.WithNumber("before_id",
+ mcp.Description("The ID of the sub-issue to be prioritized before (either after_id OR before_id should be specified)"),
+ ),
+ ),
+ func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
+ owner, err := RequiredParam[string](request, "owner")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+ repo, err := RequiredParam[string](request, "repo")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+ issueNumber, err := RequiredInt(request, "issue_number")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+ subIssueID, err := RequiredInt(request, "sub_issue_id")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+
+ // Handle optional positioning parameters
+ afterID, err := OptionalIntParam(request, "after_id")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+ beforeID, err := OptionalIntParam(request, "before_id")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+
+ // Validate that either after_id or before_id is specified, but not both
+ if afterID == 0 && beforeID == 0 {
+ return mcp.NewToolResultError("either after_id or before_id must be specified"), nil
+ }
+ if afterID != 0 && beforeID != 0 {
+ return mcp.NewToolResultError("only one of after_id or before_id should be specified, not both"), nil
+ }
+
+ client, err := getClient(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get GitHub client: %w", err)
+ }
+
+ subIssueRequest := github.SubIssueRequest{
+ SubIssueID: int64(subIssueID),
+ }
+
+ if afterID != 0 {
+ afterIDInt64 := int64(afterID)
+ subIssueRequest.AfterID = &afterIDInt64
+ }
+ if beforeID != 0 {
+ beforeIDInt64 := int64(beforeID)
+ subIssueRequest.BeforeID = &beforeIDInt64
+ }
+
+ subIssue, resp, err := client.SubIssue.Reprioritize(ctx, owner, repo, int64(issueNumber), subIssueRequest)
+ if err != nil {
+ return ghErrors.NewGitHubAPIErrorResponse(ctx,
+ "failed to reprioritize sub-issue",
+ resp,
+ err,
+ ), nil
+ }
+
+ defer func() { _ = resp.Body.Close() }()
+
+ if resp.StatusCode != http.StatusOK {
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, fmt.Errorf("failed to read response body: %w", err)
+ }
+ return mcp.NewToolResultError(fmt.Sprintf("failed to reprioritize sub-issue: %s", string(body))), nil
+ }
+
+ r, err := json.Marshal(subIssue)
+ if err != nil {
+ return nil, fmt.Errorf("failed to marshal response: %w", err)
+ }
+
+ return mcp.NewToolResultText(string(r)), nil
+ }
+}
+
// SearchIssues creates a tool to search for issues.
func SearchIssues(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
return mcp.NewTool("search_issues",
@@ -166,10 +714,10 @@ func SearchIssues(getClient GetClientFn, t translations.TranslationHelperFunc) (
mcp.Description("Search query using GitHub issues search syntax"),
),
mcp.WithString("owner",
- mcp.Description("Optional repository owner. If provided with repo, only notifications for this repository are listed."),
+ mcp.Description("Optional repository owner. If provided with repo, only issues for this repository are listed."),
),
mcp.WithString("repo",
- mcp.Description("Optional repository name. If provided with owner, only notifications for this repository are listed."),
+ mcp.Description("Optional repository name. If provided with owner, only issues for this repository are listed."),
),
mcp.WithString("sort",
mcp.Description("Sort field by number of matches of categories, defaults to best match"),
@@ -240,6 +788,9 @@ func CreateIssue(getClient GetClientFn, t translations.TranslationHelperFunc) (t
mcp.WithNumber("milestone",
mcp.Description("Milestone number"),
),
+ mcp.WithString("type",
+ mcp.Description("Type of this issue"),
+ ),
),
func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
owner, err := RequiredParam[string](request, "owner")
@@ -284,6 +835,12 @@ func CreateIssue(getClient GetClientFn, t translations.TranslationHelperFunc) (t
milestoneNum = &milestone
}
+ // Get optional type
+ issueType, err := OptionalParam[string](request, "type")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+
// Create the issue request
issueRequest := &github.IssueRequest{
Title: github.Ptr(title),
@@ -293,6 +850,10 @@ func CreateIssue(getClient GetClientFn, t translations.TranslationHelperFunc) (t
Milestone: milestoneNum,
}
+ if issueType != "" {
+ issueRequest.Type = github.Ptr(issueType)
+ }
+
client, err := getClient(ctx)
if err != nil {
return nil, fmt.Errorf("failed to get GitHub client: %w", err)
@@ -311,7 +872,12 @@ func CreateIssue(getClient GetClientFn, t translations.TranslationHelperFunc) (t
return mcp.NewToolResultError(fmt.Sprintf("failed to create issue: %s", string(body))), nil
}
- r, err := json.Marshal(issue)
+ // Return minimal response with just essential information
+ minimalResponse := MinimalResponse{
+ URL: issue.GetHTMLURL(),
+ }
+
+ r, err := json.Marshal(minimalResponse)
if err != nil {
return nil, fmt.Errorf("failed to marshal response: %w", err)
}
@@ -321,9 +887,9 @@ func CreateIssue(getClient GetClientFn, t translations.TranslationHelperFunc) (t
}
// ListIssues creates a tool to list and filter repository issues
-func ListIssues(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
+func ListIssues(getGQLClient GetGQLClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
return mcp.NewTool("list_issues",
- mcp.WithDescription(t("TOOL_LIST_ISSUES_DESCRIPTION", "List issues in a GitHub repository.")),
+ mcp.WithDescription(t("TOOL_LIST_ISSUES_DESCRIPTION", "List issues in a GitHub repository. For pagination, use the 'endCursor' from the previous response's 'pageInfo' in the 'after' parameter.")),
mcp.WithToolAnnotation(mcp.ToolAnnotation{
Title: t("TOOL_LIST_ISSUES_USER_TITLE", "List issues"),
ReadOnlyHint: ToBoolPtr(true),
@@ -337,8 +903,8 @@ func ListIssues(getClient GetClientFn, t translations.TranslationHelperFunc) (to
mcp.Description("Repository name"),
),
mcp.WithString("state",
- mcp.Description("Filter by state"),
- mcp.Enum("open", "closed", "all"),
+ mcp.Description("Filter by state, by default both open and closed issues are returned when not provided"),
+ mcp.Enum("OPEN", "CLOSED"),
),
mcp.WithArray("labels",
mcp.Description("Filter by labels"),
@@ -348,18 +914,18 @@ func ListIssues(getClient GetClientFn, t translations.TranslationHelperFunc) (to
},
),
),
- mcp.WithString("sort",
- mcp.Description("Sort order"),
- mcp.Enum("created", "updated", "comments"),
+ mcp.WithString("orderBy",
+ mcp.Description("Order issues by field. If provided, the 'direction' also needs to be provided."),
+ mcp.Enum("CREATED_AT", "UPDATED_AT", "COMMENTS"),
),
mcp.WithString("direction",
- mcp.Description("Sort direction"),
- mcp.Enum("asc", "desc"),
+ mcp.Description("Order direction. If provided, the 'orderBy' also needs to be provided."),
+ mcp.Enum("ASC", "DESC"),
),
mcp.WithString("since",
mcp.Description("Filter by date (ISO 8601 timestamp)"),
),
- WithPagination(),
+ WithCursorPagination(),
),
func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
owner, err := RequiredParam[string](request, "owner")
@@ -371,74 +937,164 @@ func ListIssues(getClient GetClientFn, t translations.TranslationHelperFunc) (to
return mcp.NewToolResultError(err.Error()), nil
}
- opts := &github.IssueListByRepoOptions{}
-
// Set optional parameters if provided
- opts.State, err = OptionalParam[string](request, "state")
+ state, err := OptionalParam[string](request, "state")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
+ // If the state has a value, cast into an array of strings
+ var states []githubv4.IssueState
+ if state != "" {
+ states = append(states, githubv4.IssueState(state))
+ } else {
+ states = []githubv4.IssueState{githubv4.IssueStateOpen, githubv4.IssueStateClosed}
+ }
+
// Get labels
- opts.Labels, err = OptionalStringArrayParam(request, "labels")
+ labels, err := OptionalStringArrayParam(request, "labels")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
- opts.Sort, err = OptionalParam[string](request, "sort")
+ orderBy, err := OptionalParam[string](request, "orderBy")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
- opts.Direction, err = OptionalParam[string](request, "direction")
+ direction, err := OptionalParam[string](request, "direction")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
+ // These variables are required for the GraphQL query to be set by default
+ // If orderBy is empty, default to CREATED_AT
+ if orderBy == "" {
+ orderBy = "CREATED_AT"
+ }
+ // If direction is empty, default to DESC
+ if direction == "" {
+ direction = "DESC"
+ }
+
since, err := OptionalParam[string](request, "since")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
+
+ // There are two optional parameters: since and labels.
+ var sinceTime time.Time
+ var hasSince bool
if since != "" {
- timestamp, err := parseISOTimestamp(since)
+ sinceTime, err = parseISOTimestamp(since)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("failed to list issues: %s", err.Error())), nil
}
- opts.Since = timestamp
+ hasSince = true
}
+ hasLabels := len(labels) > 0
- if page, ok := request.GetArguments()["page"].(float64); ok {
- opts.ListOptions.Page = int(page)
+ // Get pagination parameters and convert to GraphQL format
+ pagination, err := OptionalCursorPaginationParams(request)
+ if err != nil {
+ return nil, err
}
- if perPage, ok := request.GetArguments()["perPage"].(float64); ok {
- opts.ListOptions.PerPage = int(perPage)
+ // Check if someone tried to use page-based pagination instead of cursor-based
+ if _, pageProvided := request.GetArguments()["page"]; pageProvided {
+ return mcp.NewToolResultError("This tool uses cursor-based pagination. Use the 'after' parameter with the 'endCursor' value from the previous response instead of 'page'."), nil
}
- client, err := getClient(ctx)
+ // Check if pagination parameters were explicitly provided
+ _, perPageProvided := request.GetArguments()["perPage"]
+ paginationExplicit := perPageProvided
+
+ paginationParams, err := pagination.ToGraphQLParams()
if err != nil {
- return nil, fmt.Errorf("failed to get GitHub client: %w", err)
+ return nil, err
}
- issues, resp, err := client.Issues.ListByRepo(ctx, owner, repo, opts)
+
+ // Use default of 30 if pagination was not explicitly provided
+ if !paginationExplicit {
+ defaultFirst := int32(DefaultGraphQLPageSize)
+ paginationParams.First = &defaultFirst
+ }
+
+ client, err := getGQLClient(ctx)
if err != nil {
- return nil, fmt.Errorf("failed to list issues: %w", err)
+ return mcp.NewToolResultError(fmt.Sprintf("failed to get GitHub GQL client: %v", err)), nil
}
- defer func() { _ = resp.Body.Close() }()
- if resp.StatusCode != http.StatusOK {
- body, err := io.ReadAll(resp.Body)
- if err != nil {
- return nil, fmt.Errorf("failed to read response body: %w", err)
+ vars := map[string]interface{}{
+ "owner": githubv4.String(owner),
+ "repo": githubv4.String(repo),
+ "states": states,
+ "orderBy": githubv4.IssueOrderField(orderBy),
+ "direction": githubv4.OrderDirection(direction),
+ "first": githubv4.Int(*paginationParams.First),
+ }
+
+ if paginationParams.After != nil {
+ vars["after"] = githubv4.String(*paginationParams.After)
+ } else {
+ // Used within query, therefore must be set to nil and provided as $after
+ vars["after"] = (*githubv4.String)(nil)
+ }
+
+ // Ensure optional parameters are set
+ if hasLabels {
+ // Use query with labels filtering - convert string labels to githubv4.String slice
+ labelStrings := make([]githubv4.String, len(labels))
+ for i, label := range labels {
+ labelStrings[i] = githubv4.String(label)
}
- return mcp.NewToolResultError(fmt.Sprintf("failed to list issues: %s", string(body))), nil
+ vars["labels"] = labelStrings
}
- r, err := json.Marshal(issues)
+ if hasSince {
+ vars["since"] = githubv4.DateTime{Time: sinceTime}
+ }
+
+ issueQuery := getIssueQueryType(hasLabels, hasSince)
+ if err := client.Query(ctx, issueQuery, vars); err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+
+ // Extract and convert all issue nodes using the common interface
+ var issues []*github.Issue
+ var pageInfo struct {
+ HasNextPage githubv4.Boolean
+ HasPreviousPage githubv4.Boolean
+ StartCursor githubv4.String
+ EndCursor githubv4.String
+ }
+ var totalCount int
+
+ if queryResult, ok := issueQuery.(IssueQueryResult); ok {
+ fragment := queryResult.GetIssueFragment()
+ for _, issue := range fragment.Nodes {
+ issues = append(issues, fragmentToIssue(issue))
+ }
+ pageInfo = fragment.PageInfo
+ totalCount = fragment.TotalCount
+ }
+
+ // Create response with issues
+ response := map[string]interface{}{
+ "issues": issues,
+ "pageInfo": map[string]interface{}{
+ "hasNextPage": pageInfo.HasNextPage,
+ "hasPreviousPage": pageInfo.HasPreviousPage,
+ "startCursor": string(pageInfo.StartCursor),
+ "endCursor": string(pageInfo.EndCursor),
+ },
+ "totalCount": totalCount,
+ }
+ out, err := json.Marshal(response)
if err != nil {
return nil, fmt.Errorf("failed to marshal issues: %w", err)
}
-
- return mcp.NewToolResultText(string(r)), nil
+ return mcp.NewToolResultText(string(out)), nil
}
}
@@ -491,6 +1147,9 @@ func UpdateIssue(getClient GetClientFn, t translations.TranslationHelperFunc) (t
mcp.WithNumber("milestone",
mcp.Description("New milestone number"),
),
+ mcp.WithString("type",
+ mcp.Description("New issue type"),
+ ),
),
func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
owner, err := RequiredParam[string](request, "owner")
@@ -561,6 +1220,15 @@ func UpdateIssue(getClient GetClientFn, t translations.TranslationHelperFunc) (t
issueRequest.Milestone = &milestoneNum
}
+ // Get issue type
+ issueType, err := OptionalParam[string](request, "type")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+ if issueType != "" {
+ issueRequest.Type = github.Ptr(issueType)
+ }
+
client, err := getClient(ctx)
if err != nil {
return nil, fmt.Errorf("failed to get GitHub client: %w", err)
@@ -579,7 +1247,12 @@ func UpdateIssue(getClient GetClientFn, t translations.TranslationHelperFunc) (t
return mcp.NewToolResultError(fmt.Sprintf("failed to update issue: %s", string(body))), nil
}
- r, err := json.Marshal(updatedIssue)
+ // Return minimal response with just essential information
+ minimalResponse := MinimalResponse{
+ URL: updatedIssue.GetHTMLURL(),
+ }
+
+ r, err := json.Marshal(minimalResponse)
if err != nil {
return nil, fmt.Errorf("failed to marshal response: %w", err)
}
@@ -630,8 +1303,8 @@ func GetIssueComments(getClient GetClientFn, t translations.TranslationHelperFun
opts := &github.IssueListCommentsOptions{
ListOptions: github.ListOptions{
- Page: pagination.page,
- PerPage: pagination.perPage,
+ Page: pagination.Page,
+ PerPage: pagination.PerPage,
},
}
@@ -889,7 +1562,7 @@ func AssignCodingAgentPrompt(t translations.TranslationHelperFunc) (tool mcp.Pro
messages := []mcp.PromptMessage{
{
- Role: "system",
+ Role: "user",
Content: mcp.NewTextContent("You are a personal assistant for GitHub the Copilot GitHub Coding Agent. Your task is to help the user assign tasks to the Coding Agent based on their open GitHub issues. You can use `assign_copilot_to_issue` tool to assign the Coding Agent to issues that are suitable for autonomous work, and `search_issues` tool to find issues that match the user's criteria. You can also use `list_issues` to get a list of issues in the repository."),
},
{
diff --git a/pkg/github/issues_test.go b/pkg/github/issues_test.go
index 146259477..5a0d409a6 100644
--- a/pkg/github/issues_test.go
+++ b/pkg/github/issues_test.go
@@ -5,13 +5,14 @@ import (
"encoding/json"
"fmt"
"net/http"
+ "strings"
"testing"
"time"
"github.com/github/github-mcp-server/internal/githubv4mock"
"github.com/github/github-mcp-server/internal/toolsnaps"
"github.com/github/github-mcp-server/pkg/translations"
- "github.com/google/go-github/v73/github"
+ "github.com/google/go-github/v74/github"
"github.com/migueleliasweb/go-github-mock/src/mock"
"github.com/shurcooL/githubv4"
"github.com/stretchr/testify/assert"
@@ -38,6 +39,9 @@ func Test_GetIssue(t *testing.T) {
Body: github.Ptr("This is a test issue"),
State: github.Ptr("open"),
HTMLURL: github.Ptr("https://github.com/owner/repo/issues/42"),
+ User: &github.User{
+ Login: github.Ptr("testuser"),
+ },
}
tests := []struct {
@@ -111,6 +115,9 @@ func Test_GetIssue(t *testing.T) {
assert.Equal(t, *tc.expectedIssue.Number, *returnedIssue.Number)
assert.Equal(t, *tc.expectedIssue.Title, *returnedIssue.Title)
assert.Equal(t, *tc.expectedIssue.Body, *returnedIssue.Body)
+ assert.Equal(t, *tc.expectedIssue.State, *returnedIssue.State)
+ assert.Equal(t, *tc.expectedIssue.HTMLURL, *returnedIssue.HTMLURL)
+ assert.Equal(t, *tc.expectedIssue.User.Login, *returnedIssue.User.Login)
})
}
}
@@ -404,6 +411,100 @@ func Test_SearchIssues(t *testing.T) {
expectError: false,
expectedResult: mockSearchResult,
},
+ {
+ name: "query with existing is:issue filter - no duplication",
+ mockedClient: mock.NewMockedHTTPClient(
+ mock.WithRequestMatchHandler(
+ mock.GetSearchIssues,
+ expectQueryParams(
+ t,
+ map[string]string{
+ "q": "repo:github/github-mcp-server is:issue is:open (label:critical OR label:urgent)",
+ "page": "1",
+ "per_page": "30",
+ },
+ ).andThen(
+ mockResponse(t, http.StatusOK, mockSearchResult),
+ ),
+ ),
+ ),
+ requestArgs: map[string]interface{}{
+ "query": "repo:github/github-mcp-server is:issue is:open (label:critical OR label:urgent)",
+ },
+ expectError: false,
+ expectedResult: mockSearchResult,
+ },
+ {
+ name: "query with existing repo: filter and conflicting owner/repo params - uses query filter",
+ mockedClient: mock.NewMockedHTTPClient(
+ mock.WithRequestMatchHandler(
+ mock.GetSearchIssues,
+ expectQueryParams(
+ t,
+ map[string]string{
+ "q": "is:issue repo:github/github-mcp-server critical",
+ "page": "1",
+ "per_page": "30",
+ },
+ ).andThen(
+ mockResponse(t, http.StatusOK, mockSearchResult),
+ ),
+ ),
+ ),
+ requestArgs: map[string]interface{}{
+ "query": "repo:github/github-mcp-server critical",
+ "owner": "different-owner",
+ "repo": "different-repo",
+ },
+ expectError: false,
+ expectedResult: mockSearchResult,
+ },
+ {
+ name: "query with both is: and repo: filters already present",
+ mockedClient: mock.NewMockedHTTPClient(
+ mock.WithRequestMatchHandler(
+ mock.GetSearchIssues,
+ expectQueryParams(
+ t,
+ map[string]string{
+ "q": "is:issue repo:octocat/Hello-World bug",
+ "page": "1",
+ "per_page": "30",
+ },
+ ).andThen(
+ mockResponse(t, http.StatusOK, mockSearchResult),
+ ),
+ ),
+ ),
+ requestArgs: map[string]interface{}{
+ "query": "is:issue repo:octocat/Hello-World bug",
+ },
+ expectError: false,
+ expectedResult: mockSearchResult,
+ },
+ {
+ name: "complex query with multiple OR operators and existing filters",
+ mockedClient: mock.NewMockedHTTPClient(
+ mock.WithRequestMatchHandler(
+ mock.GetSearchIssues,
+ expectQueryParams(
+ 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",
+ },
+ ).andThen(
+ mockResponse(t, http.StatusOK, mockSearchResult),
+ ),
+ ),
+ ),
+ requestArgs: map[string]interface{}{
+ "query": "repo:github/github-mcp-server is:issue (label:critical OR label:urgent OR label:high-priority OR label:blocker)",
+ },
+ expectError: false,
+ expectedResult: mockSearchResult,
+ },
{
name: "search issues fails",
mockedClient: mock.NewMockedHTTPClient(
@@ -480,6 +581,7 @@ func Test_CreateIssue(t *testing.T) {
assert.Contains(t, tool.InputSchema.Properties, "assignees")
assert.Contains(t, tool.InputSchema.Properties, "labels")
assert.Contains(t, tool.InputSchema.Properties, "milestone")
+ assert.Contains(t, tool.InputSchema.Properties, "type")
assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "title"})
// Setup mock issue for success case
@@ -492,6 +594,7 @@ func Test_CreateIssue(t *testing.T) {
Assignees: []*github.User{{Login: github.Ptr("user1")}, {Login: github.Ptr("user2")}},
Labels: []*github.Label{{Name: github.Ptr("bug")}, {Name: github.Ptr("help wanted")}},
Milestone: &github.Milestone{Number: github.Ptr(5)},
+ Type: &github.IssueType{Name: github.Ptr("Bug")},
}
tests := []struct {
@@ -513,6 +616,7 @@ func Test_CreateIssue(t *testing.T) {
"labels": []any{"bug", "help wanted"},
"assignees": []any{"user1", "user2"},
"milestone": float64(5),
+ "type": "Bug",
}).andThen(
mockResponse(t, http.StatusCreated, mockIssue),
),
@@ -526,6 +630,7 @@ func Test_CreateIssue(t *testing.T) {
"assignees": []any{"user1", "user2"},
"labels": []any{"bug", "help wanted"},
"milestone": float64(5),
+ "type": "Bug",
},
expectError: false,
expectedIssue: mockIssue,
@@ -607,43 +712,20 @@ func Test_CreateIssue(t *testing.T) {
require.NoError(t, err)
textContent := getTextResult(t, result)
- // Unmarshal and verify the result
- var returnedIssue github.Issue
+ // Unmarshal and verify the minimal result
+ var returnedIssue MinimalResponse
err = json.Unmarshal([]byte(textContent.Text), &returnedIssue)
require.NoError(t, err)
- assert.Equal(t, *tc.expectedIssue.Number, *returnedIssue.Number)
- assert.Equal(t, *tc.expectedIssue.Title, *returnedIssue.Title)
- assert.Equal(t, *tc.expectedIssue.State, *returnedIssue.State)
- assert.Equal(t, *tc.expectedIssue.HTMLURL, *returnedIssue.HTMLURL)
-
- if tc.expectedIssue.Body != nil {
- assert.Equal(t, *tc.expectedIssue.Body, *returnedIssue.Body)
- }
-
- // Check assignees if expected
- if len(tc.expectedIssue.Assignees) > 0 {
- assert.Equal(t, len(tc.expectedIssue.Assignees), len(returnedIssue.Assignees))
- for i, assignee := range returnedIssue.Assignees {
- assert.Equal(t, *tc.expectedIssue.Assignees[i].Login, *assignee.Login)
- }
- }
-
- // Check labels if expected
- if len(tc.expectedIssue.Labels) > 0 {
- assert.Equal(t, len(tc.expectedIssue.Labels), len(returnedIssue.Labels))
- for i, label := range returnedIssue.Labels {
- assert.Equal(t, *tc.expectedIssue.Labels[i].Name, *label.Name)
- }
- }
+ assert.Equal(t, tc.expectedIssue.GetHTMLURL(), returnedIssue.URL)
})
}
}
func Test_ListIssues(t *testing.T) {
// Verify tool definition
- mockClient := github.NewClient(nil)
- tool, _ := ListIssues(stubGetClientFn(mockClient), translations.NullTranslationHelper)
+ mockClient := githubv4.NewClient(nil)
+ tool, _ := ListIssues(stubGetGQLClientFn(mockClient), translations.NullTranslationHelper)
require.NoError(t, toolsnaps.Test(tool.Name, tool))
assert.Equal(t, "list_issues", tool.Name)
@@ -652,166 +734,297 @@ func Test_ListIssues(t *testing.T) {
assert.Contains(t, tool.InputSchema.Properties, "repo")
assert.Contains(t, tool.InputSchema.Properties, "state")
assert.Contains(t, tool.InputSchema.Properties, "labels")
- assert.Contains(t, tool.InputSchema.Properties, "sort")
+ assert.Contains(t, tool.InputSchema.Properties, "orderBy")
assert.Contains(t, tool.InputSchema.Properties, "direction")
assert.Contains(t, tool.InputSchema.Properties, "since")
- assert.Contains(t, tool.InputSchema.Properties, "page")
+ assert.Contains(t, tool.InputSchema.Properties, "after")
assert.Contains(t, tool.InputSchema.Properties, "perPage")
assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo"})
- // Setup mock issues for success case
- mockIssues := []*github.Issue{
+ // Mock issues data
+ mockIssuesAll := []map[string]any{
+ {
+ "number": 123,
+ "title": "First Issue",
+ "body": "This is the first test issue",
+ "state": "OPEN",
+ "databaseId": 1001,
+ "createdAt": "2023-01-01T00:00:00Z",
+ "updatedAt": "2023-01-01T00:00:00Z",
+ "author": map[string]any{"login": "user1"},
+ "labels": map[string]any{
+ "nodes": []map[string]any{
+ {"name": "bug", "id": "label1", "description": "Bug label"},
+ },
+ },
+ "comments": map[string]any{
+ "totalCount": 5,
+ },
+ },
{
- Number: github.Ptr(123),
- Title: github.Ptr("First Issue"),
- Body: github.Ptr("This is the first test issue"),
- State: github.Ptr("open"),
- HTMLURL: github.Ptr("https://github.com/owner/repo/issues/123"),
- CreatedAt: &github.Timestamp{Time: time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC)},
+ "number": 456,
+ "title": "Second Issue",
+ "body": "This is the second test issue",
+ "state": "OPEN",
+ "databaseId": 1002,
+ "createdAt": "2023-02-01T00:00:00Z",
+ "updatedAt": "2023-02-01T00:00:00Z",
+ "author": map[string]any{"login": "user2"},
+ "labels": map[string]any{
+ "nodes": []map[string]any{
+ {"name": "enhancement", "id": "label2", "description": "Enhancement label"},
+ },
+ },
+ "comments": map[string]any{
+ "totalCount": 3,
+ },
},
+ }
+
+ mockIssuesOpen := []map[string]any{mockIssuesAll[0], mockIssuesAll[1]}
+ mockIssuesClosed := []map[string]any{
{
- Number: github.Ptr(456),
- Title: github.Ptr("Second Issue"),
- Body: github.Ptr("This is the second test issue"),
- State: github.Ptr("open"),
- HTMLURL: github.Ptr("https://github.com/owner/repo/issues/456"),
- Labels: []*github.Label{{Name: github.Ptr("bug")}},
- CreatedAt: &github.Timestamp{Time: time.Date(2023, 2, 1, 0, 0, 0, 0, time.UTC)},
+ "number": 789,
+ "title": "Closed Issue",
+ "body": "This is a closed issue",
+ "state": "CLOSED",
+ "databaseId": 1003,
+ "createdAt": "2023-03-01T00:00:00Z",
+ "updatedAt": "2023-03-01T00:00:00Z",
+ "author": map[string]any{"login": "user3"},
+ "labels": map[string]any{
+ "nodes": []map[string]any{},
+ },
+ "comments": map[string]any{
+ "totalCount": 1,
+ },
+ },
+ }
+
+ // Mock responses
+ mockResponseListAll := githubv4mock.DataResponse(map[string]any{
+ "repository": map[string]any{
+ "issues": map[string]any{
+ "nodes": mockIssuesAll,
+ "pageInfo": map[string]any{
+ "hasNextPage": false,
+ "hasPreviousPage": false,
+ "startCursor": "",
+ "endCursor": "",
+ },
+ "totalCount": 2,
+ },
},
+ })
+
+ mockResponseOpenOnly := githubv4mock.DataResponse(map[string]any{
+ "repository": map[string]any{
+ "issues": map[string]any{
+ "nodes": mockIssuesOpen,
+ "pageInfo": map[string]any{
+ "hasNextPage": false,
+ "hasPreviousPage": false,
+ "startCursor": "",
+ "endCursor": "",
+ },
+ "totalCount": 2,
+ },
+ },
+ })
+
+ mockResponseClosedOnly := githubv4mock.DataResponse(map[string]any{
+ "repository": map[string]any{
+ "issues": map[string]any{
+ "nodes": mockIssuesClosed,
+ "pageInfo": map[string]any{
+ "hasNextPage": false,
+ "hasPreviousPage": false,
+ "startCursor": "",
+ "endCursor": "",
+ },
+ "totalCount": 1,
+ },
+ },
+ })
+
+ mockErrorRepoNotFound := githubv4mock.ErrorResponse("repository not found")
+
+ // Variables matching what GraphQL receives after JSON marshaling/unmarshaling
+ varsListAll := map[string]interface{}{
+ "owner": "owner",
+ "repo": "repo",
+ "states": []interface{}{"OPEN", "CLOSED"},
+ "orderBy": "CREATED_AT",
+ "direction": "DESC",
+ "first": float64(30),
+ "after": (*string)(nil),
+ }
+
+ varsOpenOnly := map[string]interface{}{
+ "owner": "owner",
+ "repo": "repo",
+ "states": []interface{}{"OPEN"},
+ "orderBy": "CREATED_AT",
+ "direction": "DESC",
+ "first": float64(30),
+ "after": (*string)(nil),
+ }
+
+ varsClosedOnly := map[string]interface{}{
+ "owner": "owner",
+ "repo": "repo",
+ "states": []interface{}{"CLOSED"},
+ "orderBy": "CREATED_AT",
+ "direction": "DESC",
+ "first": float64(30),
+ "after": (*string)(nil),
+ }
+
+ varsWithLabels := map[string]interface{}{
+ "owner": "owner",
+ "repo": "repo",
+ "states": []interface{}{"OPEN", "CLOSED"},
+ "labels": []interface{}{"bug", "enhancement"},
+ "orderBy": "CREATED_AT",
+ "direction": "DESC",
+ "first": float64(30),
+ "after": (*string)(nil),
+ }
+
+ varsRepoNotFound := map[string]interface{}{
+ "owner": "owner",
+ "repo": "nonexistent-repo",
+ "states": []interface{}{"OPEN", "CLOSED"},
+ "orderBy": "CREATED_AT",
+ "direction": "DESC",
+ "first": float64(30),
+ "after": (*string)(nil),
}
tests := []struct {
- name string
- mockedClient *http.Client
- requestArgs map[string]interface{}
- expectError bool
- expectedIssues []*github.Issue
- expectedErrMsg string
+ name string
+ reqParams map[string]interface{}
+ expectError bool
+ errContains string
+ expectedCount int
+ verifyOrder func(t *testing.T, issues []*github.Issue)
}{
{
- name: "list issues with minimal parameters",
- mockedClient: mock.NewMockedHTTPClient(
- mock.WithRequestMatch(
- mock.GetReposIssuesByOwnerByRepo,
- mockIssues,
- ),
- ),
- requestArgs: map[string]interface{}{
+ name: "list all issues",
+ reqParams: map[string]interface{}{
"owner": "owner",
"repo": "repo",
},
- expectError: false,
- expectedIssues: mockIssues,
+ expectError: false,
+ expectedCount: 2,
},
{
- name: "list issues with all parameters",
- mockedClient: mock.NewMockedHTTPClient(
- mock.WithRequestMatchHandler(
- mock.GetReposIssuesByOwnerByRepo,
- expectQueryParams(t, map[string]string{
- "state": "open",
- "labels": "bug,enhancement",
- "sort": "created",
- "direction": "desc",
- "since": "2023-01-01T00:00:00Z",
- "page": "1",
- "per_page": "30",
- }).andThen(
- mockResponse(t, http.StatusOK, mockIssues),
- ),
- ),
- ),
- requestArgs: map[string]interface{}{
- "owner": "owner",
- "repo": "repo",
- "state": "open",
- "labels": []any{"bug", "enhancement"},
- "sort": "created",
- "direction": "desc",
- "since": "2023-01-01T00:00:00Z",
- "page": float64(1),
- "perPage": float64(30),
+ name: "filter by open state",
+ reqParams: map[string]interface{}{
+ "owner": "owner",
+ "repo": "repo",
+ "state": "OPEN",
},
- expectError: false,
- expectedIssues: mockIssues,
+ expectError: false,
+ expectedCount: 2,
},
{
- name: "invalid since parameter",
- mockedClient: mock.NewMockedHTTPClient(
- mock.WithRequestMatch(
- mock.GetReposIssuesByOwnerByRepo,
- mockIssues,
- ),
- ),
- requestArgs: map[string]interface{}{
+ name: "filter by closed state",
+ reqParams: map[string]interface{}{
"owner": "owner",
"repo": "repo",
- "since": "invalid-date",
+ "state": "CLOSED",
},
- expectError: true,
- expectedErrMsg: "invalid ISO 8601 timestamp",
+ expectError: false,
+ expectedCount: 1,
},
{
- name: "list issues fails with error",
- mockedClient: mock.NewMockedHTTPClient(
- mock.WithRequestMatchHandler(
- mock.GetReposIssuesByOwnerByRepo,
- http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
- w.WriteHeader(http.StatusNotFound)
- _, _ = w.Write([]byte(`{"message": "Repository not found"}`))
- }),
- ),
- ),
- requestArgs: map[string]interface{}{
- "owner": "nonexistent",
- "repo": "repo",
+ name: "filter by labels",
+ reqParams: map[string]interface{}{
+ "owner": "owner",
+ "repo": "repo",
+ "labels": []any{"bug", "enhancement"},
},
- expectError: true,
- expectedErrMsg: "failed to list issues",
+ expectError: false,
+ expectedCount: 2,
+ },
+ {
+ name: "repository not found error",
+ reqParams: map[string]interface{}{
+ "owner": "owner",
+ "repo": "nonexistent-repo",
+ },
+ expectError: true,
+ errContains: "repository not found",
},
}
+ // Define the actual query strings that match the implementation
+ qBasicNoLabels := "query($after:String$direction:OrderDirection!$first:Int!$orderBy:IssueOrderField!$owner:String!$repo:String!$states:[IssueState!]!){repository(owner: $owner, name: $repo){issues(first: $first, after: $after, states: $states, orderBy: {field: $orderBy, direction: $direction}){nodes{number,title,body,state,databaseId,author{login},createdAt,updatedAt,labels(first: 100){nodes{name,id,description}},comments{totalCount}},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor},totalCount}}}"
+ qWithLabels := "query($after:String$direction:OrderDirection!$first:Int!$labels:[String!]!$orderBy:IssueOrderField!$owner:String!$repo:String!$states:[IssueState!]!){repository(owner: $owner, name: $repo){issues(first: $first, after: $after, labels: $labels, states: $states, orderBy: {field: $orderBy, direction: $direction}){nodes{number,title,body,state,databaseId,author{login},createdAt,updatedAt,labels(first: 100){nodes{name,id,description}},comments{totalCount}},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor},totalCount}}}"
+
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
- // Setup client with mock
- client := github.NewClient(tc.mockedClient)
- _, handler := ListIssues(stubGetClientFn(client), translations.NullTranslationHelper)
+ var httpClient *http.Client
+
+ switch tc.name {
+ case "list all issues":
+ matcher := githubv4mock.NewQueryMatcher(qBasicNoLabels, varsListAll, mockResponseListAll)
+ httpClient = githubv4mock.NewMockedHTTPClient(matcher)
+ case "filter by open state":
+ matcher := githubv4mock.NewQueryMatcher(qBasicNoLabels, varsOpenOnly, mockResponseOpenOnly)
+ httpClient = githubv4mock.NewMockedHTTPClient(matcher)
+ case "filter by closed state":
+ matcher := githubv4mock.NewQueryMatcher(qBasicNoLabels, varsClosedOnly, mockResponseClosedOnly)
+ httpClient = githubv4mock.NewMockedHTTPClient(matcher)
+ case "filter by labels":
+ matcher := githubv4mock.NewQueryMatcher(qWithLabels, varsWithLabels, mockResponseListAll)
+ httpClient = githubv4mock.NewMockedHTTPClient(matcher)
+ case "repository not found error":
+ matcher := githubv4mock.NewQueryMatcher(qBasicNoLabels, varsRepoNotFound, mockErrorRepoNotFound)
+ httpClient = githubv4mock.NewMockedHTTPClient(matcher)
+ }
- // Create call request
- request := createMCPRequest(tc.requestArgs)
+ gqlClient := githubv4.NewClient(httpClient)
+ _, handler := ListIssues(stubGetGQLClientFn(gqlClient), translations.NullTranslationHelper)
- // Call handler
- result, err := handler(context.Background(), request)
+ req := createMCPRequest(tc.reqParams)
+ res, err := handler(context.Background(), req)
+ text := getTextResult(t, res).Text
- // Verify results
if tc.expectError {
- if err != nil {
- assert.Contains(t, err.Error(), tc.expectedErrMsg)
- } else {
- // For errors returned as part of the result, not as an error
- assert.NotNil(t, result)
- textContent := getTextResult(t, result)
- assert.Contains(t, textContent.Text, tc.expectedErrMsg)
- }
+ require.True(t, res.IsError)
+ assert.Contains(t, text, tc.errContains)
return
}
+ require.NoError(t, err)
+ // Parse the structured response with pagination info
+ var response struct {
+ Issues []*github.Issue `json:"issues"`
+ PageInfo struct {
+ HasNextPage bool `json:"hasNextPage"`
+ HasPreviousPage bool `json:"hasPreviousPage"`
+ StartCursor string `json:"startCursor"`
+ EndCursor string `json:"endCursor"`
+ } `json:"pageInfo"`
+ TotalCount int `json:"totalCount"`
+ }
+ err = json.Unmarshal([]byte(text), &response)
require.NoError(t, err)
- // Parse the result and get the text content if no error
- textContent := getTextResult(t, result)
+ assert.Len(t, response.Issues, tc.expectedCount, "Expected %d issues, got %d", tc.expectedCount, len(response.Issues))
- // Unmarshal and verify the result
- var returnedIssues []*github.Issue
- err = json.Unmarshal([]byte(textContent.Text), &returnedIssues)
- require.NoError(t, err)
+ // Verify order if verifyOrder function is provided
+ if tc.verifyOrder != nil {
+ tc.verifyOrder(t, response.Issues)
+ }
- assert.Len(t, returnedIssues, len(tc.expectedIssues))
- for i, issue := range returnedIssues {
- assert.Equal(t, *tc.expectedIssues[i].Number, *issue.Number)
- assert.Equal(t, *tc.expectedIssues[i].Title, *issue.Title)
- assert.Equal(t, *tc.expectedIssues[i].State, *issue.State)
- assert.Equal(t, *tc.expectedIssues[i].HTMLURL, *issue.HTMLURL)
+ // Verify that returned issues have expected structure
+ for _, issue := range response.Issues {
+ assert.NotNil(t, issue.Number, "Issue should have number")
+ assert.NotNil(t, issue.Title, "Issue should have title")
+ assert.NotNil(t, issue.State, "Issue should have state")
}
})
}
@@ -834,6 +1047,7 @@ func Test_UpdateIssue(t *testing.T) {
assert.Contains(t, tool.InputSchema.Properties, "labels")
assert.Contains(t, tool.InputSchema.Properties, "assignees")
assert.Contains(t, tool.InputSchema.Properties, "milestone")
+ assert.Contains(t, tool.InputSchema.Properties, "type")
assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "issue_number"})
// Setup mock issue for success case
@@ -846,6 +1060,7 @@ func Test_UpdateIssue(t *testing.T) {
Assignees: []*github.User{{Login: github.Ptr("assignee1")}, {Login: github.Ptr("assignee2")}},
Labels: []*github.Label{{Name: github.Ptr("bug")}, {Name: github.Ptr("priority")}},
Milestone: &github.Milestone{Number: github.Ptr(5)},
+ Type: &github.IssueType{Name: github.Ptr("Bug")},
}
tests := []struct {
@@ -868,6 +1083,7 @@ func Test_UpdateIssue(t *testing.T) {
"labels": []any{"bug", "priority"},
"assignees": []any{"assignee1", "assignee2"},
"milestone": float64(5),
+ "type": "Bug",
}).andThen(
mockResponse(t, http.StatusOK, mockIssue),
),
@@ -883,6 +1099,7 @@ func Test_UpdateIssue(t *testing.T) {
"labels": []any{"bug", "priority"},
"assignees": []any{"assignee1", "assignee2"},
"milestone": float64(5),
+ "type": "Bug",
},
expectError: false,
expectedIssue: mockIssue,
@@ -894,9 +1111,10 @@ func Test_UpdateIssue(t *testing.T) {
mock.PatchReposIssuesByOwnerByRepoByIssueNumber,
mockResponse(t, http.StatusOK, &github.Issue{
Number: github.Ptr(123),
- Title: github.Ptr("Only Title Updated"),
+ Title: github.Ptr("Updated Issue Title"),
HTMLURL: github.Ptr("https://github.com/owner/repo/issues/123"),
State: github.Ptr("open"),
+ Type: &github.IssueType{Name: github.Ptr("Feature")},
}),
),
),
@@ -904,14 +1122,16 @@ func Test_UpdateIssue(t *testing.T) {
"owner": "owner",
"repo": "repo",
"issue_number": float64(123),
- "title": "Only Title Updated",
+ "title": "Updated Issue Title",
+ "type": "Feature",
},
expectError: false,
expectedIssue: &github.Issue{
Number: github.Ptr(123),
- Title: github.Ptr("Only Title Updated"),
+ Title: github.Ptr("Updated Issue Title"),
HTMLURL: github.Ptr("https://github.com/owner/repo/issues/123"),
State: github.Ptr("open"),
+ Type: &github.IssueType{Name: github.Ptr("Feature")},
},
},
{
@@ -986,41 +1206,12 @@ func Test_UpdateIssue(t *testing.T) {
// Parse the result and get the text content if no error
textContent := getTextResult(t, result)
- // Unmarshal and verify the result
- var returnedIssue github.Issue
- err = json.Unmarshal([]byte(textContent.Text), &returnedIssue)
+ // Unmarshal and verify the minimal result
+ var updateResp MinimalResponse
+ err = json.Unmarshal([]byte(textContent.Text), &updateResp)
require.NoError(t, err)
- assert.Equal(t, *tc.expectedIssue.Number, *returnedIssue.Number)
- assert.Equal(t, *tc.expectedIssue.Title, *returnedIssue.Title)
- assert.Equal(t, *tc.expectedIssue.State, *returnedIssue.State)
- assert.Equal(t, *tc.expectedIssue.HTMLURL, *returnedIssue.HTMLURL)
-
- if tc.expectedIssue.Body != nil {
- assert.Equal(t, *tc.expectedIssue.Body, *returnedIssue.Body)
- }
-
- // Check assignees if expected
- if len(tc.expectedIssue.Assignees) > 0 {
- assert.Len(t, returnedIssue.Assignees, len(tc.expectedIssue.Assignees))
- for i, assignee := range returnedIssue.Assignees {
- assert.Equal(t, *tc.expectedIssue.Assignees[i].Login, *assignee.Login)
- }
- }
-
- // Check labels if expected
- if len(tc.expectedIssue.Labels) > 0 {
- assert.Len(t, returnedIssue.Labels, len(tc.expectedIssue.Labels))
- for i, label := range returnedIssue.Labels {
- assert.Equal(t, *tc.expectedIssue.Labels[i].Name, *label.Name)
- }
- }
-
- // Check milestone if expected
- if tc.expectedIssue.Milestone != nil {
- assert.NotNil(t, returnedIssue.Milestone)
- assert.Equal(t, *tc.expectedIssue.Milestone.Number, *returnedIssue.Milestone.Number)
- }
+ assert.Equal(t, tc.expectedIssue.GetHTMLURL(), updateResp.URL)
})
}
}
@@ -1629,3 +1820,1112 @@ func TestAssignCopilotToIssue(t *testing.T) {
})
}
}
+
+func Test_AddSubIssue(t *testing.T) {
+ // Verify tool definition once
+ mockClient := github.NewClient(nil)
+ tool, _ := AddSubIssue(stubGetClientFn(mockClient), translations.NullTranslationHelper)
+ require.NoError(t, toolsnaps.Test(tool.Name, tool))
+
+ assert.Equal(t, "add_sub_issue", tool.Name)
+ 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, "issue_number")
+ assert.Contains(t, tool.InputSchema.Properties, "sub_issue_id")
+ assert.Contains(t, tool.InputSchema.Properties, "replace_parent")
+ assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "issue_number", "sub_issue_id"})
+
+ // Setup mock issue for success case (matches GitHub API response format)
+ mockIssue := &github.Issue{
+ Number: github.Ptr(42),
+ Title: github.Ptr("Parent Issue"),
+ Body: github.Ptr("This is the parent issue with a sub-issue"),
+ State: github.Ptr("open"),
+ HTMLURL: github.Ptr("https://github.com/owner/repo/issues/42"),
+ User: &github.User{
+ Login: github.Ptr("testuser"),
+ },
+ Labels: []*github.Label{
+ {
+ Name: github.Ptr("enhancement"),
+ Color: github.Ptr("84b6eb"),
+ Description: github.Ptr("New feature or request"),
+ },
+ },
+ }
+
+ tests := []struct {
+ name string
+ mockedClient *http.Client
+ requestArgs map[string]interface{}
+ expectError bool
+ expectedIssue *github.Issue
+ expectedErrMsg string
+ }{
+ {
+ name: "successful sub-issue addition with all parameters",
+ mockedClient: mock.NewMockedHTTPClient(
+ mock.WithRequestMatchHandler(
+ mock.PostReposIssuesSubIssuesByOwnerByRepoByIssueNumber,
+ mockResponse(t, http.StatusCreated, mockIssue),
+ ),
+ ),
+ requestArgs: map[string]interface{}{
+ "owner": "owner",
+ "repo": "repo",
+ "issue_number": float64(42),
+ "sub_issue_id": float64(123),
+ "replace_parent": true,
+ },
+ expectError: false,
+ expectedIssue: mockIssue,
+ },
+ {
+ name: "successful sub-issue addition with minimal parameters",
+ mockedClient: mock.NewMockedHTTPClient(
+ mock.WithRequestMatchHandler(
+ mock.PostReposIssuesSubIssuesByOwnerByRepoByIssueNumber,
+ mockResponse(t, http.StatusCreated, mockIssue),
+ ),
+ ),
+ requestArgs: map[string]interface{}{
+ "owner": "owner",
+ "repo": "repo",
+ "issue_number": float64(42),
+ "sub_issue_id": float64(456),
+ },
+ expectError: false,
+ expectedIssue: mockIssue,
+ },
+ {
+ name: "successful sub-issue addition with replace_parent false",
+ mockedClient: mock.NewMockedHTTPClient(
+ mock.WithRequestMatchHandler(
+ mock.PostReposIssuesSubIssuesByOwnerByRepoByIssueNumber,
+ mockResponse(t, http.StatusCreated, mockIssue),
+ ),
+ ),
+ requestArgs: map[string]interface{}{
+ "owner": "owner",
+ "repo": "repo",
+ "issue_number": float64(42),
+ "sub_issue_id": float64(789),
+ "replace_parent": false,
+ },
+ expectError: false,
+ expectedIssue: mockIssue,
+ },
+ {
+ name: "parent issue not found",
+ mockedClient: mock.NewMockedHTTPClient(
+ mock.WithRequestMatchHandler(
+ mock.PostReposIssuesSubIssuesByOwnerByRepoByIssueNumber,
+ mockResponse(t, http.StatusNotFound, `{"message": "Parent issue not found"}`),
+ ),
+ ),
+ requestArgs: map[string]interface{}{
+ "owner": "owner",
+ "repo": "repo",
+ "issue_number": float64(999),
+ "sub_issue_id": float64(123),
+ },
+ expectError: false,
+ expectedErrMsg: "failed to add sub-issue",
+ },
+ {
+ name: "sub-issue not found",
+ mockedClient: mock.NewMockedHTTPClient(
+ mock.WithRequestMatchHandler(
+ mock.PostReposIssuesSubIssuesByOwnerByRepoByIssueNumber,
+ mockResponse(t, http.StatusNotFound, `{"message": "Sub-issue not found"}`),
+ ),
+ ),
+ requestArgs: map[string]interface{}{
+ "owner": "owner",
+ "repo": "repo",
+ "issue_number": float64(42),
+ "sub_issue_id": float64(999),
+ },
+ expectError: false,
+ expectedErrMsg: "failed to add sub-issue",
+ },
+ {
+ name: "validation failed - sub-issue cannot be parent of itself",
+ mockedClient: mock.NewMockedHTTPClient(
+ mock.WithRequestMatchHandler(
+ mock.PostReposIssuesSubIssuesByOwnerByRepoByIssueNumber,
+ mockResponse(t, http.StatusUnprocessableEntity, `{"message": "Validation failed", "errors": [{"message": "Sub-issue cannot be a parent of itself"}]}`),
+ ),
+ ),
+ requestArgs: map[string]interface{}{
+ "owner": "owner",
+ "repo": "repo",
+ "issue_number": float64(42),
+ "sub_issue_id": float64(42),
+ },
+ expectError: false,
+ expectedErrMsg: "failed to add sub-issue",
+ },
+ {
+ name: "insufficient permissions",
+ mockedClient: mock.NewMockedHTTPClient(
+ mock.WithRequestMatchHandler(
+ mock.PostReposIssuesSubIssuesByOwnerByRepoByIssueNumber,
+ mockResponse(t, http.StatusForbidden, `{"message": "Must have write access to repository"}`),
+ ),
+ ),
+ requestArgs: map[string]interface{}{
+ "owner": "owner",
+ "repo": "repo",
+ "issue_number": float64(42),
+ "sub_issue_id": float64(123),
+ },
+ expectError: false,
+ expectedErrMsg: "failed to add sub-issue",
+ },
+ {
+ name: "missing required parameter owner",
+ mockedClient: mock.NewMockedHTTPClient(
+ // No mocked requests needed since validation fails before HTTP call
+ ),
+ requestArgs: map[string]interface{}{
+ "repo": "repo",
+ "issue_number": float64(42),
+ "sub_issue_id": float64(123),
+ },
+ expectError: false,
+ expectedErrMsg: "missing required parameter: owner",
+ },
+ {
+ name: "missing required parameter sub_issue_id",
+ mockedClient: mock.NewMockedHTTPClient(
+ // No mocked requests needed since validation fails before HTTP call
+ ),
+ requestArgs: map[string]interface{}{
+ "owner": "owner",
+ "repo": "repo",
+ "issue_number": float64(42),
+ },
+ expectError: false,
+ expectedErrMsg: "missing required parameter: sub_issue_id",
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ // Setup client with mock
+ client := github.NewClient(tc.mockedClient)
+ _, handler := AddSubIssue(stubGetClientFn(client), translations.NullTranslationHelper)
+
+ // Create call request
+ request := createMCPRequest(tc.requestArgs)
+
+ // Call handler
+ result, err := handler(context.Background(), request)
+
+ // Verify results
+ if tc.expectError {
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), tc.expectedErrMsg)
+ return
+ }
+
+ if tc.expectedErrMsg != "" {
+ require.NotNil(t, result)
+ textContent := getTextResult(t, result)
+ assert.Contains(t, textContent.Text, tc.expectedErrMsg)
+ return
+ }
+
+ require.NoError(t, err)
+
+ // Parse the result and get the text content if no error
+ textContent := getTextResult(t, result)
+
+ // Unmarshal and verify the result
+ var returnedIssue github.Issue
+ err = json.Unmarshal([]byte(textContent.Text), &returnedIssue)
+ require.NoError(t, err)
+ assert.Equal(t, *tc.expectedIssue.Number, *returnedIssue.Number)
+ assert.Equal(t, *tc.expectedIssue.Title, *returnedIssue.Title)
+ assert.Equal(t, *tc.expectedIssue.Body, *returnedIssue.Body)
+ assert.Equal(t, *tc.expectedIssue.State, *returnedIssue.State)
+ assert.Equal(t, *tc.expectedIssue.HTMLURL, *returnedIssue.HTMLURL)
+ assert.Equal(t, *tc.expectedIssue.User.Login, *returnedIssue.User.Login)
+ })
+ }
+}
+
+func Test_ListSubIssues(t *testing.T) {
+ // Verify tool definition once
+ mockClient := github.NewClient(nil)
+ tool, _ := ListSubIssues(stubGetClientFn(mockClient), translations.NullTranslationHelper)
+ require.NoError(t, toolsnaps.Test(tool.Name, tool))
+
+ assert.Equal(t, "list_sub_issues", tool.Name)
+ 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, "issue_number")
+ assert.Contains(t, tool.InputSchema.Properties, "page")
+ assert.Contains(t, tool.InputSchema.Properties, "per_page")
+ assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "issue_number"})
+
+ // Setup mock sub-issues for success case
+ mockSubIssues := []*github.Issue{
+ {
+ Number: github.Ptr(123),
+ Title: github.Ptr("Sub-issue 1"),
+ Body: github.Ptr("This is the first sub-issue"),
+ State: github.Ptr("open"),
+ HTMLURL: github.Ptr("https://github.com/owner/repo/issues/123"),
+ User: &github.User{
+ Login: github.Ptr("user1"),
+ },
+ Labels: []*github.Label{
+ {
+ Name: github.Ptr("bug"),
+ Color: github.Ptr("d73a4a"),
+ Description: github.Ptr("Something isn't working"),
+ },
+ },
+ },
+ {
+ Number: github.Ptr(124),
+ Title: github.Ptr("Sub-issue 2"),
+ Body: github.Ptr("This is the second sub-issue"),
+ State: github.Ptr("closed"),
+ HTMLURL: github.Ptr("https://github.com/owner/repo/issues/124"),
+ User: &github.User{
+ Login: github.Ptr("user2"),
+ },
+ Assignees: []*github.User{
+ {Login: github.Ptr("assignee1")},
+ },
+ },
+ }
+
+ tests := []struct {
+ name string
+ mockedClient *http.Client
+ requestArgs map[string]interface{}
+ expectError bool
+ expectedSubIssues []*github.Issue
+ expectedErrMsg string
+ }{
+ {
+ name: "successful sub-issues listing with minimal parameters",
+ mockedClient: mock.NewMockedHTTPClient(
+ mock.WithRequestMatch(
+ mock.GetReposIssuesSubIssuesByOwnerByRepoByIssueNumber,
+ mockSubIssues,
+ ),
+ ),
+ requestArgs: map[string]interface{}{
+ "owner": "owner",
+ "repo": "repo",
+ "issue_number": float64(42),
+ },
+ expectError: false,
+ expectedSubIssues: mockSubIssues,
+ },
+ {
+ name: "successful sub-issues listing with pagination",
+ mockedClient: mock.NewMockedHTTPClient(
+ mock.WithRequestMatchHandler(
+ mock.GetReposIssuesSubIssuesByOwnerByRepoByIssueNumber,
+ expectQueryParams(t, map[string]string{
+ "page": "2",
+ "per_page": "10",
+ }).andThen(
+ mockResponse(t, http.StatusOK, mockSubIssues),
+ ),
+ ),
+ ),
+ requestArgs: map[string]interface{}{
+ "owner": "owner",
+ "repo": "repo",
+ "issue_number": float64(42),
+ "page": float64(2),
+ "per_page": float64(10),
+ },
+ expectError: false,
+ expectedSubIssues: mockSubIssues,
+ },
+ {
+ name: "successful sub-issues listing with empty result",
+ mockedClient: mock.NewMockedHTTPClient(
+ mock.WithRequestMatch(
+ mock.GetReposIssuesSubIssuesByOwnerByRepoByIssueNumber,
+ []*github.Issue{},
+ ),
+ ),
+ requestArgs: map[string]interface{}{
+ "owner": "owner",
+ "repo": "repo",
+ "issue_number": float64(42),
+ },
+ expectError: false,
+ expectedSubIssues: []*github.Issue{},
+ },
+ {
+ name: "parent issue not found",
+ mockedClient: mock.NewMockedHTTPClient(
+ mock.WithRequestMatchHandler(
+ mock.GetReposIssuesSubIssuesByOwnerByRepoByIssueNumber,
+ mockResponse(t, http.StatusNotFound, `{"message": "Not Found"}`),
+ ),
+ ),
+ requestArgs: map[string]interface{}{
+ "owner": "owner",
+ "repo": "repo",
+ "issue_number": float64(999),
+ },
+ expectError: false,
+ expectedErrMsg: "failed to list sub-issues",
+ },
+ {
+ name: "repository not found",
+ mockedClient: mock.NewMockedHTTPClient(
+ mock.WithRequestMatchHandler(
+ mock.GetReposIssuesSubIssuesByOwnerByRepoByIssueNumber,
+ mockResponse(t, http.StatusNotFound, `{"message": "Not Found"}`),
+ ),
+ ),
+ requestArgs: map[string]interface{}{
+ "owner": "nonexistent",
+ "repo": "repo",
+ "issue_number": float64(42),
+ },
+ expectError: false,
+ expectedErrMsg: "failed to list sub-issues",
+ },
+ {
+ name: "sub-issues feature gone/deprecated",
+ mockedClient: mock.NewMockedHTTPClient(
+ mock.WithRequestMatchHandler(
+ mock.GetReposIssuesSubIssuesByOwnerByRepoByIssueNumber,
+ mockResponse(t, http.StatusGone, `{"message": "This feature has been deprecated"}`),
+ ),
+ ),
+ requestArgs: map[string]interface{}{
+ "owner": "owner",
+ "repo": "repo",
+ "issue_number": float64(42),
+ },
+ expectError: false,
+ expectedErrMsg: "failed to list sub-issues",
+ },
+ {
+ name: "missing required parameter owner",
+ mockedClient: mock.NewMockedHTTPClient(
+ // No mocked requests needed since validation fails before HTTP call
+ ),
+ requestArgs: map[string]interface{}{
+ "repo": "repo",
+ "issue_number": float64(42),
+ },
+ expectError: false,
+ expectedErrMsg: "missing required parameter: owner",
+ },
+ {
+ name: "missing required parameter issue_number",
+ mockedClient: mock.NewMockedHTTPClient(
+ // No mocked requests needed since validation fails before HTTP call
+ ),
+ requestArgs: map[string]interface{}{
+ "owner": "owner",
+ "repo": "repo",
+ },
+ expectError: false,
+ expectedErrMsg: "missing required parameter: issue_number",
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ // Setup client with mock
+ client := github.NewClient(tc.mockedClient)
+ _, handler := ListSubIssues(stubGetClientFn(client), translations.NullTranslationHelper)
+
+ // Create call request
+ request := createMCPRequest(tc.requestArgs)
+
+ // Call handler
+ result, err := handler(context.Background(), request)
+
+ // Verify results
+ if tc.expectError {
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), tc.expectedErrMsg)
+ return
+ }
+
+ if tc.expectedErrMsg != "" {
+ require.NotNil(t, result)
+ textContent := getTextResult(t, result)
+ assert.Contains(t, textContent.Text, tc.expectedErrMsg)
+ return
+ }
+
+ require.NoError(t, err)
+
+ // Parse the result and get the text content if no error
+ textContent := getTextResult(t, result)
+
+ // Unmarshal and verify the result
+ var returnedSubIssues []*github.Issue
+ err = json.Unmarshal([]byte(textContent.Text), &returnedSubIssues)
+ require.NoError(t, err)
+
+ assert.Len(t, returnedSubIssues, len(tc.expectedSubIssues))
+ for i, subIssue := range returnedSubIssues {
+ if i < len(tc.expectedSubIssues) {
+ assert.Equal(t, *tc.expectedSubIssues[i].Number, *subIssue.Number)
+ assert.Equal(t, *tc.expectedSubIssues[i].Title, *subIssue.Title)
+ assert.Equal(t, *tc.expectedSubIssues[i].State, *subIssue.State)
+ assert.Equal(t, *tc.expectedSubIssues[i].HTMLURL, *subIssue.HTMLURL)
+ assert.Equal(t, *tc.expectedSubIssues[i].User.Login, *subIssue.User.Login)
+
+ if tc.expectedSubIssues[i].Body != nil {
+ assert.Equal(t, *tc.expectedSubIssues[i].Body, *subIssue.Body)
+ }
+ }
+ }
+ })
+ }
+}
+
+func Test_RemoveSubIssue(t *testing.T) {
+ // Verify tool definition once
+ mockClient := github.NewClient(nil)
+ tool, _ := RemoveSubIssue(stubGetClientFn(mockClient), translations.NullTranslationHelper)
+ require.NoError(t, toolsnaps.Test(tool.Name, tool))
+
+ assert.Equal(t, "remove_sub_issue", tool.Name)
+ 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, "issue_number")
+ assert.Contains(t, tool.InputSchema.Properties, "sub_issue_id")
+ assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "issue_number", "sub_issue_id"})
+
+ // Setup mock issue for success case (matches GitHub API response format - the updated parent issue)
+ mockIssue := &github.Issue{
+ Number: github.Ptr(42),
+ Title: github.Ptr("Parent Issue"),
+ Body: github.Ptr("This is the parent issue after sub-issue removal"),
+ State: github.Ptr("open"),
+ HTMLURL: github.Ptr("https://github.com/owner/repo/issues/42"),
+ User: &github.User{
+ Login: github.Ptr("testuser"),
+ },
+ Labels: []*github.Label{
+ {
+ Name: github.Ptr("enhancement"),
+ Color: github.Ptr("84b6eb"),
+ Description: github.Ptr("New feature or request"),
+ },
+ },
+ }
+
+ tests := []struct {
+ name string
+ mockedClient *http.Client
+ requestArgs map[string]interface{}
+ expectError bool
+ expectedIssue *github.Issue
+ expectedErrMsg string
+ }{
+ {
+ name: "successful sub-issue removal",
+ mockedClient: mock.NewMockedHTTPClient(
+ mock.WithRequestMatchHandler(
+ mock.DeleteReposIssuesSubIssueByOwnerByRepoByIssueNumber,
+ mockResponse(t, http.StatusOK, mockIssue),
+ ),
+ ),
+ requestArgs: map[string]interface{}{
+ "owner": "owner",
+ "repo": "repo",
+ "issue_number": float64(42),
+ "sub_issue_id": float64(123),
+ },
+ expectError: false,
+ expectedIssue: mockIssue,
+ },
+ {
+ name: "parent issue not found",
+ mockedClient: mock.NewMockedHTTPClient(
+ mock.WithRequestMatchHandler(
+ mock.DeleteReposIssuesSubIssueByOwnerByRepoByIssueNumber,
+ mockResponse(t, http.StatusNotFound, `{"message": "Not Found"}`),
+ ),
+ ),
+ requestArgs: map[string]interface{}{
+ "owner": "owner",
+ "repo": "repo",
+ "issue_number": float64(999),
+ "sub_issue_id": float64(123),
+ },
+ expectError: false,
+ expectedErrMsg: "failed to remove sub-issue",
+ },
+ {
+ name: "sub-issue not found",
+ mockedClient: mock.NewMockedHTTPClient(
+ mock.WithRequestMatchHandler(
+ mock.DeleteReposIssuesSubIssueByOwnerByRepoByIssueNumber,
+ mockResponse(t, http.StatusNotFound, `{"message": "Sub-issue not found"}`),
+ ),
+ ),
+ requestArgs: map[string]interface{}{
+ "owner": "owner",
+ "repo": "repo",
+ "issue_number": float64(42),
+ "sub_issue_id": float64(999),
+ },
+ expectError: false,
+ expectedErrMsg: "failed to remove sub-issue",
+ },
+ {
+ name: "bad request - invalid sub_issue_id",
+ mockedClient: mock.NewMockedHTTPClient(
+ mock.WithRequestMatchHandler(
+ mock.DeleteReposIssuesSubIssueByOwnerByRepoByIssueNumber,
+ mockResponse(t, http.StatusBadRequest, `{"message": "Invalid sub_issue_id"}`),
+ ),
+ ),
+ requestArgs: map[string]interface{}{
+ "owner": "owner",
+ "repo": "repo",
+ "issue_number": float64(42),
+ "sub_issue_id": float64(-1),
+ },
+ expectError: false,
+ expectedErrMsg: "failed to remove sub-issue",
+ },
+ {
+ name: "repository not found",
+ mockedClient: mock.NewMockedHTTPClient(
+ mock.WithRequestMatchHandler(
+ mock.DeleteReposIssuesSubIssueByOwnerByRepoByIssueNumber,
+ mockResponse(t, http.StatusNotFound, `{"message": "Not Found"}`),
+ ),
+ ),
+ requestArgs: map[string]interface{}{
+ "owner": "nonexistent",
+ "repo": "repo",
+ "issue_number": float64(42),
+ "sub_issue_id": float64(123),
+ },
+ expectError: false,
+ expectedErrMsg: "failed to remove sub-issue",
+ },
+ {
+ name: "insufficient permissions",
+ mockedClient: mock.NewMockedHTTPClient(
+ mock.WithRequestMatchHandler(
+ mock.DeleteReposIssuesSubIssueByOwnerByRepoByIssueNumber,
+ mockResponse(t, http.StatusForbidden, `{"message": "Must have write access to repository"}`),
+ ),
+ ),
+ requestArgs: map[string]interface{}{
+ "owner": "owner",
+ "repo": "repo",
+ "issue_number": float64(42),
+ "sub_issue_id": float64(123),
+ },
+ expectError: false,
+ expectedErrMsg: "failed to remove sub-issue",
+ },
+ {
+ name: "missing required parameter owner",
+ mockedClient: mock.NewMockedHTTPClient(
+ // No mocked requests needed since validation fails before HTTP call
+ ),
+ requestArgs: map[string]interface{}{
+ "repo": "repo",
+ "issue_number": float64(42),
+ "sub_issue_id": float64(123),
+ },
+ expectError: false,
+ expectedErrMsg: "missing required parameter: owner",
+ },
+ {
+ name: "missing required parameter sub_issue_id",
+ mockedClient: mock.NewMockedHTTPClient(
+ // No mocked requests needed since validation fails before HTTP call
+ ),
+ requestArgs: map[string]interface{}{
+ "owner": "owner",
+ "repo": "repo",
+ "issue_number": float64(42),
+ },
+ expectError: false,
+ expectedErrMsg: "missing required parameter: sub_issue_id",
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ // Setup client with mock
+ client := github.NewClient(tc.mockedClient)
+ _, handler := RemoveSubIssue(stubGetClientFn(client), translations.NullTranslationHelper)
+
+ // Create call request
+ request := createMCPRequest(tc.requestArgs)
+
+ // Call handler
+ result, err := handler(context.Background(), request)
+
+ // Verify results
+ if tc.expectError {
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), tc.expectedErrMsg)
+ return
+ }
+
+ if tc.expectedErrMsg != "" {
+ require.NotNil(t, result)
+ textContent := getTextResult(t, result)
+ assert.Contains(t, textContent.Text, tc.expectedErrMsg)
+ return
+ }
+
+ require.NoError(t, err)
+
+ // Parse the result and get the text content if no error
+ textContent := getTextResult(t, result)
+
+ // Unmarshal and verify the result
+ var returnedIssue github.Issue
+ err = json.Unmarshal([]byte(textContent.Text), &returnedIssue)
+ require.NoError(t, err)
+ assert.Equal(t, *tc.expectedIssue.Number, *returnedIssue.Number)
+ assert.Equal(t, *tc.expectedIssue.Title, *returnedIssue.Title)
+ assert.Equal(t, *tc.expectedIssue.Body, *returnedIssue.Body)
+ assert.Equal(t, *tc.expectedIssue.State, *returnedIssue.State)
+ assert.Equal(t, *tc.expectedIssue.HTMLURL, *returnedIssue.HTMLURL)
+ assert.Equal(t, *tc.expectedIssue.User.Login, *returnedIssue.User.Login)
+ })
+ }
+}
+
+func Test_ReprioritizeSubIssue(t *testing.T) {
+ // Verify tool definition once
+ mockClient := github.NewClient(nil)
+ tool, _ := ReprioritizeSubIssue(stubGetClientFn(mockClient), translations.NullTranslationHelper)
+ require.NoError(t, toolsnaps.Test(tool.Name, tool))
+
+ assert.Equal(t, "reprioritize_sub_issue", tool.Name)
+ 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, "issue_number")
+ assert.Contains(t, tool.InputSchema.Properties, "sub_issue_id")
+ assert.Contains(t, tool.InputSchema.Properties, "after_id")
+ assert.Contains(t, tool.InputSchema.Properties, "before_id")
+ assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "issue_number", "sub_issue_id"})
+
+ // Setup mock issue for success case (matches GitHub API response format - the updated parent issue)
+ mockIssue := &github.Issue{
+ Number: github.Ptr(42),
+ Title: github.Ptr("Parent Issue"),
+ Body: github.Ptr("This is the parent issue with reprioritized sub-issues"),
+ State: github.Ptr("open"),
+ HTMLURL: github.Ptr("https://github.com/owner/repo/issues/42"),
+ User: &github.User{
+ Login: github.Ptr("testuser"),
+ },
+ Labels: []*github.Label{
+ {
+ Name: github.Ptr("enhancement"),
+ Color: github.Ptr("84b6eb"),
+ Description: github.Ptr("New feature or request"),
+ },
+ },
+ }
+
+ tests := []struct {
+ name string
+ mockedClient *http.Client
+ requestArgs map[string]interface{}
+ expectError bool
+ expectedIssue *github.Issue
+ expectedErrMsg string
+ }{
+ {
+ name: "successful reprioritization with after_id",
+ mockedClient: mock.NewMockedHTTPClient(
+ mock.WithRequestMatchHandler(
+ mock.PatchReposIssuesSubIssuesPriorityByOwnerByRepoByIssueNumber,
+ mockResponse(t, http.StatusOK, mockIssue),
+ ),
+ ),
+ requestArgs: map[string]interface{}{
+ "owner": "owner",
+ "repo": "repo",
+ "issue_number": float64(42),
+ "sub_issue_id": float64(123),
+ "after_id": float64(456),
+ },
+ expectError: false,
+ expectedIssue: mockIssue,
+ },
+ {
+ name: "successful reprioritization with before_id",
+ mockedClient: mock.NewMockedHTTPClient(
+ mock.WithRequestMatchHandler(
+ mock.PatchReposIssuesSubIssuesPriorityByOwnerByRepoByIssueNumber,
+ mockResponse(t, http.StatusOK, mockIssue),
+ ),
+ ),
+ requestArgs: map[string]interface{}{
+ "owner": "owner",
+ "repo": "repo",
+ "issue_number": float64(42),
+ "sub_issue_id": float64(123),
+ "before_id": float64(789),
+ },
+ expectError: false,
+ expectedIssue: mockIssue,
+ },
+ {
+ name: "validation error - neither after_id nor before_id specified",
+ mockedClient: mock.NewMockedHTTPClient(
+ // No mocked requests needed since validation fails before HTTP call
+ ),
+ requestArgs: map[string]interface{}{
+ "owner": "owner",
+ "repo": "repo",
+ "issue_number": float64(42),
+ "sub_issue_id": float64(123),
+ },
+ expectError: false,
+ expectedErrMsg: "either after_id or before_id must be specified",
+ },
+ {
+ name: "validation error - both after_id and before_id specified",
+ mockedClient: mock.NewMockedHTTPClient(
+ // No mocked requests needed since validation fails before HTTP call
+ ),
+ requestArgs: map[string]interface{}{
+ "owner": "owner",
+ "repo": "repo",
+ "issue_number": float64(42),
+ "sub_issue_id": float64(123),
+ "after_id": float64(456),
+ "before_id": float64(789),
+ },
+ expectError: false,
+ expectedErrMsg: "only one of after_id or before_id should be specified, not both",
+ },
+ {
+ name: "parent issue not found",
+ mockedClient: mock.NewMockedHTTPClient(
+ mock.WithRequestMatchHandler(
+ mock.PatchReposIssuesSubIssuesPriorityByOwnerByRepoByIssueNumber,
+ mockResponse(t, http.StatusNotFound, `{"message": "Not Found"}`),
+ ),
+ ),
+ requestArgs: map[string]interface{}{
+ "owner": "owner",
+ "repo": "repo",
+ "issue_number": float64(999),
+ "sub_issue_id": float64(123),
+ "after_id": float64(456),
+ },
+ expectError: false,
+ expectedErrMsg: "failed to reprioritize sub-issue",
+ },
+ {
+ name: "sub-issue not found",
+ mockedClient: mock.NewMockedHTTPClient(
+ mock.WithRequestMatchHandler(
+ mock.PatchReposIssuesSubIssuesPriorityByOwnerByRepoByIssueNumber,
+ mockResponse(t, http.StatusNotFound, `{"message": "Sub-issue not found"}`),
+ ),
+ ),
+ requestArgs: map[string]interface{}{
+ "owner": "owner",
+ "repo": "repo",
+ "issue_number": float64(42),
+ "sub_issue_id": float64(999),
+ "after_id": float64(456),
+ },
+ expectError: false,
+ expectedErrMsg: "failed to reprioritize sub-issue",
+ },
+ {
+ name: "validation failed - positioning sub-issue not found",
+ mockedClient: mock.NewMockedHTTPClient(
+ mock.WithRequestMatchHandler(
+ mock.PatchReposIssuesSubIssuesPriorityByOwnerByRepoByIssueNumber,
+ mockResponse(t, http.StatusUnprocessableEntity, `{"message": "Validation failed", "errors": [{"message": "Positioning sub-issue not found"}]}`),
+ ),
+ ),
+ requestArgs: map[string]interface{}{
+ "owner": "owner",
+ "repo": "repo",
+ "issue_number": float64(42),
+ "sub_issue_id": float64(123),
+ "after_id": float64(999),
+ },
+ expectError: false,
+ expectedErrMsg: "failed to reprioritize sub-issue",
+ },
+ {
+ name: "insufficient permissions",
+ mockedClient: mock.NewMockedHTTPClient(
+ mock.WithRequestMatchHandler(
+ mock.PatchReposIssuesSubIssuesPriorityByOwnerByRepoByIssueNumber,
+ mockResponse(t, http.StatusForbidden, `{"message": "Must have write access to repository"}`),
+ ),
+ ),
+ requestArgs: map[string]interface{}{
+ "owner": "owner",
+ "repo": "repo",
+ "issue_number": float64(42),
+ "sub_issue_id": float64(123),
+ "after_id": float64(456),
+ },
+ expectError: false,
+ expectedErrMsg: "failed to reprioritize sub-issue",
+ },
+ {
+ name: "service unavailable",
+ mockedClient: mock.NewMockedHTTPClient(
+ mock.WithRequestMatchHandler(
+ mock.PatchReposIssuesSubIssuesPriorityByOwnerByRepoByIssueNumber,
+ mockResponse(t, http.StatusServiceUnavailable, `{"message": "Service Unavailable"}`),
+ ),
+ ),
+ requestArgs: map[string]interface{}{
+ "owner": "owner",
+ "repo": "repo",
+ "issue_number": float64(42),
+ "sub_issue_id": float64(123),
+ "before_id": float64(456),
+ },
+ expectError: false,
+ expectedErrMsg: "failed to reprioritize sub-issue",
+ },
+ {
+ name: "missing required parameter owner",
+ mockedClient: mock.NewMockedHTTPClient(
+ // No mocked requests needed since validation fails before HTTP call
+ ),
+ requestArgs: map[string]interface{}{
+ "repo": "repo",
+ "issue_number": float64(42),
+ "sub_issue_id": float64(123),
+ "after_id": float64(456),
+ },
+ expectError: false,
+ expectedErrMsg: "missing required parameter: owner",
+ },
+ {
+ name: "missing required parameter sub_issue_id",
+ mockedClient: mock.NewMockedHTTPClient(
+ // No mocked requests needed since validation fails before HTTP call
+ ),
+ requestArgs: map[string]interface{}{
+ "owner": "owner",
+ "repo": "repo",
+ "issue_number": float64(42),
+ "after_id": float64(456),
+ },
+ expectError: false,
+ expectedErrMsg: "missing required parameter: sub_issue_id",
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ // Setup client with mock
+ client := github.NewClient(tc.mockedClient)
+ _, handler := ReprioritizeSubIssue(stubGetClientFn(client), translations.NullTranslationHelper)
+
+ // Create call request
+ request := createMCPRequest(tc.requestArgs)
+
+ // Call handler
+ result, err := handler(context.Background(), request)
+
+ // Verify results
+ if tc.expectError {
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), tc.expectedErrMsg)
+ return
+ }
+
+ if tc.expectedErrMsg != "" {
+ require.NotNil(t, result)
+ textContent := getTextResult(t, result)
+ assert.Contains(t, textContent.Text, tc.expectedErrMsg)
+ return
+ }
+
+ require.NoError(t, err)
+
+ // Parse the result and get the text content if no error
+ textContent := getTextResult(t, result)
+
+ // Unmarshal and verify the result
+ var returnedIssue github.Issue
+ err = json.Unmarshal([]byte(textContent.Text), &returnedIssue)
+ require.NoError(t, err)
+ assert.Equal(t, *tc.expectedIssue.Number, *returnedIssue.Number)
+ assert.Equal(t, *tc.expectedIssue.Title, *returnedIssue.Title)
+ assert.Equal(t, *tc.expectedIssue.Body, *returnedIssue.Body)
+ assert.Equal(t, *tc.expectedIssue.State, *returnedIssue.State)
+ assert.Equal(t, *tc.expectedIssue.HTMLURL, *returnedIssue.HTMLURL)
+ assert.Equal(t, *tc.expectedIssue.User.Login, *returnedIssue.User.Login)
+ })
+ }
+}
+
+func Test_ListIssueTypes(t *testing.T) {
+ // Verify tool definition once
+ mockClient := github.NewClient(nil)
+ tool, _ := ListIssueTypes(stubGetClientFn(mockClient), translations.NullTranslationHelper)
+ require.NoError(t, toolsnaps.Test(tool.Name, tool))
+
+ assert.Equal(t, "list_issue_types", tool.Name)
+ assert.NotEmpty(t, tool.Description)
+ assert.Contains(t, tool.InputSchema.Properties, "owner")
+ assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner"})
+
+ // Setup mock issue types for success case
+ mockIssueTypes := []*github.IssueType{
+ {
+ ID: github.Ptr(int64(1)),
+ Name: github.Ptr("bug"),
+ Description: github.Ptr("Something isn't working"),
+ Color: github.Ptr("d73a4a"),
+ },
+ {
+ ID: github.Ptr(int64(2)),
+ Name: github.Ptr("feature"),
+ Description: github.Ptr("New feature or enhancement"),
+ Color: github.Ptr("a2eeef"),
+ },
+ }
+
+ tests := []struct {
+ name string
+ mockedClient *http.Client
+ requestArgs map[string]interface{}
+ expectError bool
+ expectedIssueTypes []*github.IssueType
+ expectedErrMsg string
+ }{
+ {
+ name: "successful issue types retrieval",
+ mockedClient: mock.NewMockedHTTPClient(
+ mock.WithRequestMatchHandler(
+ mock.EndpointPattern{
+ Pattern: "/orgs/testorg/issue-types",
+ Method: "GET",
+ },
+ mockResponse(t, http.StatusOK, mockIssueTypes),
+ ),
+ ),
+ requestArgs: map[string]interface{}{
+ "owner": "testorg",
+ },
+ expectError: false,
+ expectedIssueTypes: mockIssueTypes,
+ },
+ {
+ name: "organization not found",
+ mockedClient: mock.NewMockedHTTPClient(
+ mock.WithRequestMatchHandler(
+ mock.EndpointPattern{
+ Pattern: "/orgs/nonexistent/issue-types",
+ Method: "GET",
+ },
+ mockResponse(t, http.StatusNotFound, `{"message": "Organization not found"}`),
+ ),
+ ),
+ requestArgs: map[string]interface{}{
+ "owner": "nonexistent",
+ },
+ expectError: true,
+ expectedErrMsg: "failed to list issue types",
+ },
+ {
+ name: "missing owner parameter",
+ mockedClient: mock.NewMockedHTTPClient(
+ mock.WithRequestMatchHandler(
+ mock.EndpointPattern{
+ Pattern: "/orgs/testorg/issue-types",
+ Method: "GET",
+ },
+ mockResponse(t, http.StatusOK, mockIssueTypes),
+ ),
+ ),
+ requestArgs: map[string]interface{}{},
+ expectError: false, // This should be handled by parameter validation, error returned in result
+ expectedErrMsg: "missing required parameter: owner",
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ // Setup client with mock
+ client := github.NewClient(tc.mockedClient)
+ _, handler := ListIssueTypes(stubGetClientFn(client), translations.NullTranslationHelper)
+
+ // Create call request
+ request := createMCPRequest(tc.requestArgs)
+
+ // Call handler
+ result, err := handler(context.Background(), request)
+
+ // Verify results
+ if tc.expectError {
+ if err != nil {
+ assert.Contains(t, err.Error(), tc.expectedErrMsg)
+ return
+ }
+ // Check if error is returned as tool result error
+ require.NotNil(t, result)
+ require.True(t, result.IsError)
+ errorContent := getErrorResult(t, result)
+ assert.Contains(t, errorContent.Text, tc.expectedErrMsg)
+ return
+ }
+
+ // Check if it's a parameter validation error (returned as tool result error)
+ if result != nil && result.IsError {
+ errorContent := getErrorResult(t, result)
+ if tc.expectedErrMsg != "" && strings.Contains(errorContent.Text, tc.expectedErrMsg) {
+ return // This is expected for parameter validation errors
+ }
+ }
+
+ require.NoError(t, err)
+ require.NotNil(t, result)
+ require.False(t, result.IsError)
+ textContent := getTextResult(t, result)
+
+ // Unmarshal and verify the result
+ var returnedIssueTypes []*github.IssueType
+ err = json.Unmarshal([]byte(textContent.Text), &returnedIssueTypes)
+ require.NoError(t, err)
+
+ if tc.expectedIssueTypes != nil {
+ require.Equal(t, len(tc.expectedIssueTypes), len(returnedIssueTypes))
+ for i, expected := range tc.expectedIssueTypes {
+ assert.Equal(t, *expected.Name, *returnedIssueTypes[i].Name)
+ assert.Equal(t, *expected.Description, *returnedIssueTypes[i].Description)
+ assert.Equal(t, *expected.Color, *returnedIssueTypes[i].Color)
+ assert.Equal(t, *expected.ID, *returnedIssueTypes[i].ID)
+ }
+ }
+ })
+ }
+}
diff --git a/pkg/github/minimal_types.go b/pkg/github/minimal_types.go
new file mode 100644
index 000000000..0c3c220aa
--- /dev/null
+++ b/pkg/github/minimal_types.go
@@ -0,0 +1,204 @@
+package github
+
+import "github.com/google/go-github/v74/github"
+
+// MinimalUser is the output type for user and organization search results.
+type MinimalUser struct {
+ Login string `json:"login"`
+ ID int64 `json:"id,omitempty"`
+ ProfileURL string `json:"profile_url,omitempty"`
+ AvatarURL string `json:"avatar_url,omitempty"`
+ Details *UserDetails `json:"details,omitempty"` // Optional field for additional user details
+}
+
+// MinimalSearchUsersResult is the trimmed output type for user search results.
+type MinimalSearchUsersResult struct {
+ TotalCount int `json:"total_count"`
+ IncompleteResults bool `json:"incomplete_results"`
+ Items []MinimalUser `json:"items"`
+}
+
+// MinimalRepository is the trimmed output type for repository objects to reduce verbosity.
+type MinimalRepository struct {
+ ID int64 `json:"id"`
+ Name string `json:"name"`
+ FullName string `json:"full_name"`
+ Description string `json:"description,omitempty"`
+ HTMLURL string `json:"html_url"`
+ Language string `json:"language,omitempty"`
+ Stars int `json:"stargazers_count"`
+ Forks int `json:"forks_count"`
+ OpenIssues int `json:"open_issues_count"`
+ UpdatedAt string `json:"updated_at,omitempty"`
+ CreatedAt string `json:"created_at,omitempty"`
+ Topics []string `json:"topics,omitempty"`
+ Private bool `json:"private"`
+ Fork bool `json:"fork"`
+ Archived bool `json:"archived"`
+ DefaultBranch string `json:"default_branch,omitempty"`
+}
+
+// MinimalSearchRepositoriesResult is the trimmed output type for repository search results.
+type MinimalSearchRepositoriesResult struct {
+ TotalCount int `json:"total_count"`
+ IncompleteResults bool `json:"incomplete_results"`
+ Items []MinimalRepository `json:"items"`
+}
+
+// MinimalCommitAuthor represents commit author information.
+type MinimalCommitAuthor struct {
+ Name string `json:"name,omitempty"`
+ Email string `json:"email,omitempty"`
+ Date string `json:"date,omitempty"`
+}
+
+// MinimalCommitInfo represents core commit information.
+type MinimalCommitInfo struct {
+ Message string `json:"message"`
+ Author *MinimalCommitAuthor `json:"author,omitempty"`
+ Committer *MinimalCommitAuthor `json:"committer,omitempty"`
+}
+
+// MinimalCommitStats represents commit statistics.
+type MinimalCommitStats struct {
+ Additions int `json:"additions,omitempty"`
+ Deletions int `json:"deletions,omitempty"`
+ Total int `json:"total,omitempty"`
+}
+
+// MinimalCommitFile represents a file changed in a commit.
+type MinimalCommitFile struct {
+ Filename string `json:"filename"`
+ Status string `json:"status,omitempty"`
+ Additions int `json:"additions,omitempty"`
+ Deletions int `json:"deletions,omitempty"`
+ Changes int `json:"changes,omitempty"`
+}
+
+// MinimalCommit is the trimmed output type for commit objects.
+type MinimalCommit struct {
+ SHA string `json:"sha"`
+ HTMLURL string `json:"html_url"`
+ Commit *MinimalCommitInfo `json:"commit,omitempty"`
+ Author *MinimalUser `json:"author,omitempty"`
+ Committer *MinimalUser `json:"committer,omitempty"`
+ Stats *MinimalCommitStats `json:"stats,omitempty"`
+ Files []MinimalCommitFile `json:"files,omitempty"`
+}
+
+// MinimalRelease is the trimmed output type for release objects.
+type MinimalRelease struct {
+ ID int64 `json:"id"`
+ TagName string `json:"tag_name"`
+ Name string `json:"name,omitempty"`
+ Body string `json:"body,omitempty"`
+ HTMLURL string `json:"html_url"`
+ PublishedAt string `json:"published_at,omitempty"`
+ Prerelease bool `json:"prerelease"`
+ Draft bool `json:"draft"`
+ Author *MinimalUser `json:"author,omitempty"`
+}
+
+// MinimalBranch is the trimmed output type for branch objects.
+type MinimalBranch struct {
+ Name string `json:"name"`
+ SHA string `json:"sha"`
+ Protected bool `json:"protected"`
+}
+
+// MinimalResponse represents a minimal response for all CRUD operations.
+// Success is implicit in the HTTP response status, and all other information
+// can be derived from the URL or fetched separately if needed.
+type MinimalResponse struct {
+ URL string `json:"url"`
+}
+
+// Helper functions
+
+// convertToMinimalCommit converts a GitHub API RepositoryCommit to MinimalCommit
+func convertToMinimalCommit(commit *github.RepositoryCommit, includeDiffs bool) MinimalCommit {
+ minimalCommit := MinimalCommit{
+ SHA: commit.GetSHA(),
+ HTMLURL: commit.GetHTMLURL(),
+ }
+
+ if commit.Commit != nil {
+ minimalCommit.Commit = &MinimalCommitInfo{
+ Message: commit.Commit.GetMessage(),
+ }
+
+ if commit.Commit.Author != nil {
+ minimalCommit.Commit.Author = &MinimalCommitAuthor{
+ Name: commit.Commit.Author.GetName(),
+ Email: commit.Commit.Author.GetEmail(),
+ }
+ if commit.Commit.Author.Date != nil {
+ minimalCommit.Commit.Author.Date = commit.Commit.Author.Date.Format("2006-01-02T15:04:05Z")
+ }
+ }
+
+ if commit.Commit.Committer != nil {
+ minimalCommit.Commit.Committer = &MinimalCommitAuthor{
+ Name: commit.Commit.Committer.GetName(),
+ Email: commit.Commit.Committer.GetEmail(),
+ }
+ if commit.Commit.Committer.Date != nil {
+ minimalCommit.Commit.Committer.Date = commit.Commit.Committer.Date.Format("2006-01-02T15:04:05Z")
+ }
+ }
+ }
+
+ if commit.Author != nil {
+ minimalCommit.Author = &MinimalUser{
+ Login: commit.Author.GetLogin(),
+ ID: commit.Author.GetID(),
+ ProfileURL: commit.Author.GetHTMLURL(),
+ AvatarURL: commit.Author.GetAvatarURL(),
+ }
+ }
+
+ if commit.Committer != nil {
+ minimalCommit.Committer = &MinimalUser{
+ Login: commit.Committer.GetLogin(),
+ ID: commit.Committer.GetID(),
+ ProfileURL: commit.Committer.GetHTMLURL(),
+ AvatarURL: commit.Committer.GetAvatarURL(),
+ }
+ }
+
+ // Only include stats and files if includeDiffs is true
+ if includeDiffs {
+ if commit.Stats != nil {
+ minimalCommit.Stats = &MinimalCommitStats{
+ Additions: commit.Stats.GetAdditions(),
+ Deletions: commit.Stats.GetDeletions(),
+ Total: commit.Stats.GetTotal(),
+ }
+ }
+
+ if len(commit.Files) > 0 {
+ minimalCommit.Files = make([]MinimalCommitFile, 0, len(commit.Files))
+ for _, file := range commit.Files {
+ minimalFile := MinimalCommitFile{
+ Filename: file.GetFilename(),
+ Status: file.GetStatus(),
+ Additions: file.GetAdditions(),
+ Deletions: file.GetDeletions(),
+ Changes: file.GetChanges(),
+ }
+ minimalCommit.Files = append(minimalCommit.Files, minimalFile)
+ }
+ }
+ }
+
+ return minimalCommit
+}
+
+// convertToMinimalBranch converts a GitHub API Branch to MinimalBranch
+func convertToMinimalBranch(branch *github.Branch) MinimalBranch {
+ return MinimalBranch{
+ Name: branch.GetName(),
+ SHA: branch.GetCommit().GetSHA(),
+ Protected: branch.GetProtected(),
+ }
+}
diff --git a/pkg/github/notifications.go b/pkg/github/notifications.go
index a41edaf42..0ee5a6b14 100644
--- a/pkg/github/notifications.go
+++ b/pkg/github/notifications.go
@@ -11,7 +11,7 @@ import (
ghErrors "github.com/github/github-mcp-server/pkg/errors"
"github.com/github/github-mcp-server/pkg/translations"
- "github.com/google/go-github/v73/github"
+ "github.com/google/go-github/v74/github"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
)
@@ -88,8 +88,8 @@ func ListNotifications(getClient GetClientFn, t translations.TranslationHelperFu
All: filter == FilterIncludeRead,
Participating: filter == FilterOnlyParticipating,
ListOptions: github.ListOptions{
- Page: paginationParams.page,
- PerPage: paginationParams.perPage,
+ Page: paginationParams.Page,
+ PerPage: paginationParams.PerPage,
},
}
diff --git a/pkg/github/notifications_test.go b/pkg/github/notifications_test.go
index 1d2382369..268a29f6f 100644
--- a/pkg/github/notifications_test.go
+++ b/pkg/github/notifications_test.go
@@ -8,7 +8,7 @@ import (
"github.com/github/github-mcp-server/internal/toolsnaps"
"github.com/github/github-mcp-server/pkg/translations"
- "github.com/google/go-github/v73/github"
+ "github.com/google/go-github/v74/github"
"github.com/migueleliasweb/go-github-mock/src/mock"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
diff --git a/pkg/github/pullrequests.go b/pkg/github/pullrequests.go
index d98dc334d..d7547519d 100644
--- a/pkg/github/pullrequests.go
+++ b/pkg/github/pullrequests.go
@@ -8,7 +8,7 @@ import (
"net/http"
"github.com/go-viper/mapstructure/v2"
- "github.com/google/go-github/v73/github"
+ "github.com/google/go-github/v74/github"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
"github.com/shurcooL/githubv4"
@@ -193,7 +193,12 @@ func CreatePullRequest(getClient GetClientFn, t translations.TranslationHelperFu
return mcp.NewToolResultError(fmt.Sprintf("failed to create pull request: %s", string(body))), nil
}
- r, err := json.Marshal(pr)
+ // Return minimal response with just essential information
+ minimalResponse := MinimalResponse{
+ URL: pr.GetHTMLURL(),
+ }
+
+ r, err := json.Marshal(minimalResponse)
if err != nil {
return nil, fmt.Errorf("failed to marshal response: %w", err)
}
@@ -203,7 +208,7 @@ func CreatePullRequest(getClient GetClientFn, t translations.TranslationHelperFu
}
// UpdatePullRequest creates a tool to update an existing pull request.
-func UpdatePullRequest(getClient GetClientFn, t translations.TranslationHelperFunc) (mcp.Tool, server.ToolHandlerFunc) {
+func UpdatePullRequest(getClient GetClientFn, getGQLClient GetGQLClientFn, t translations.TranslationHelperFunc) (mcp.Tool, server.ToolHandlerFunc) {
return mcp.NewTool("update_pull_request",
mcp.WithDescription(t("TOOL_UPDATE_PULL_REQUEST_DESCRIPTION", "Update an existing pull request in a GitHub repository.")),
mcp.WithToolAnnotation(mcp.ToolAnnotation{
@@ -232,12 +237,21 @@ func UpdatePullRequest(getClient GetClientFn, t translations.TranslationHelperFu
mcp.Description("New state"),
mcp.Enum("open", "closed"),
),
+ mcp.WithBoolean("draft",
+ mcp.Description("Mark pull request as draft (true) or ready for review (false)"),
+ ),
mcp.WithString("base",
mcp.Description("New base branch name"),
),
mcp.WithBoolean("maintainer_can_modify",
mcp.Description("Allow maintainer edits"),
),
+ mcp.WithArray("reviewers",
+ mcp.Description("GitHub usernames to request reviews from"),
+ mcp.Items(map[string]interface{}{
+ "type": "string",
+ }),
+ ),
),
func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
owner, err := RequiredParam[string](request, "owner")
@@ -253,74 +267,216 @@ func UpdatePullRequest(getClient GetClientFn, t translations.TranslationHelperFu
return mcp.NewToolResultError(err.Error()), nil
}
+ // Check if draft parameter is provided
+ draftProvided := request.GetArguments()["draft"] != nil
+ var draftValue bool
+ if draftProvided {
+ draftValue, err = OptionalParam[bool](request, "draft")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+ }
+
// Build the update struct only with provided fields
update := &github.PullRequest{}
- updateNeeded := false
+ restUpdateNeeded := false
if title, ok, err := OptionalParamOK[string](request, "title"); err != nil {
return mcp.NewToolResultError(err.Error()), nil
} else if ok {
update.Title = github.Ptr(title)
- updateNeeded = true
+ restUpdateNeeded = true
}
if body, ok, err := OptionalParamOK[string](request, "body"); err != nil {
return mcp.NewToolResultError(err.Error()), nil
} else if ok {
update.Body = github.Ptr(body)
- updateNeeded = true
+ restUpdateNeeded = true
}
if state, ok, err := OptionalParamOK[string](request, "state"); err != nil {
return mcp.NewToolResultError(err.Error()), nil
} else if ok {
update.State = github.Ptr(state)
- updateNeeded = true
+ restUpdateNeeded = true
}
if base, ok, err := OptionalParamOK[string](request, "base"); err != nil {
return mcp.NewToolResultError(err.Error()), nil
} else if ok {
update.Base = &github.PullRequestBranch{Ref: github.Ptr(base)}
- updateNeeded = true
+ restUpdateNeeded = true
}
if maintainerCanModify, ok, err := OptionalParamOK[bool](request, "maintainer_can_modify"); err != nil {
return mcp.NewToolResultError(err.Error()), nil
} else if ok {
update.MaintainerCanModify = github.Ptr(maintainerCanModify)
- updateNeeded = true
+ restUpdateNeeded = true
+ }
+
+ // Handle reviewers separately
+ reviewers, err := OptionalStringArrayParam(request, "reviewers")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
}
- if !updateNeeded {
+ // If no updates, no draft change, and no reviewers, return error early
+ if !restUpdateNeeded && !draftProvided && len(reviewers) == 0 {
return mcp.NewToolResultError("No update parameters provided."), nil
}
+ // Handle REST API updates (title, body, state, base, maintainer_can_modify)
+ if restUpdateNeeded {
+ client, err := getClient(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get GitHub client: %w", err)
+ }
+
+ _, resp, err := client.PullRequests.Edit(ctx, owner, repo, pullNumber, update)
+ if err != nil {
+ return ghErrors.NewGitHubAPIErrorResponse(ctx,
+ "failed to update pull request",
+ resp,
+ err,
+ ), nil
+ }
+ defer func() { _ = resp.Body.Close() }()
+
+ if resp.StatusCode != http.StatusOK {
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, fmt.Errorf("failed to read response body: %w", err)
+ }
+ return mcp.NewToolResultError(fmt.Sprintf("failed to update pull request: %s", string(body))), nil
+ }
+ }
+
+ // Handle draft status changes using GraphQL
+ if draftProvided {
+ gqlClient, err := getGQLClient(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get GitHub GraphQL client: %w", err)
+ }
+
+ var prQuery struct {
+ Repository struct {
+ PullRequest struct {
+ ID githubv4.ID
+ IsDraft githubv4.Boolean
+ } `graphql:"pullRequest(number: $prNum)"`
+ } `graphql:"repository(owner: $owner, name: $repo)"`
+ }
+
+ err = gqlClient.Query(ctx, &prQuery, map[string]interface{}{
+ "owner": githubv4.String(owner),
+ "repo": githubv4.String(repo),
+ "prNum": githubv4.Int(pullNumber), // #nosec G115 - pull request numbers are always small positive integers
+ })
+ if err != nil {
+ return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "Failed to find pull request", err), nil
+ }
+
+ currentIsDraft := bool(prQuery.Repository.PullRequest.IsDraft)
+
+ if currentIsDraft != draftValue {
+ if draftValue {
+ // Convert to draft
+ var mutation struct {
+ ConvertPullRequestToDraft struct {
+ PullRequest struct {
+ ID githubv4.ID
+ IsDraft githubv4.Boolean
+ }
+ } `graphql:"convertPullRequestToDraft(input: $input)"`
+ }
+
+ err = gqlClient.Mutate(ctx, &mutation, githubv4.ConvertPullRequestToDraftInput{
+ PullRequestID: prQuery.Repository.PullRequest.ID,
+ }, nil)
+ if err != nil {
+ return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "Failed to convert pull request to draft", err), nil
+ }
+ } else {
+ // Mark as ready for review
+ var mutation struct {
+ MarkPullRequestReadyForReview struct {
+ PullRequest struct {
+ ID githubv4.ID
+ IsDraft githubv4.Boolean
+ }
+ } `graphql:"markPullRequestReadyForReview(input: $input)"`
+ }
+
+ err = gqlClient.Mutate(ctx, &mutation, githubv4.MarkPullRequestReadyForReviewInput{
+ PullRequestID: prQuery.Repository.PullRequest.ID,
+ }, nil)
+ if err != nil {
+ return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "Failed to mark pull request ready for review", err), nil
+ }
+ }
+ }
+ }
+
+ // Handle reviewer requests
+ if len(reviewers) > 0 {
+ client, err := getClient(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get GitHub client: %w", err)
+ }
+
+ reviewersRequest := github.ReviewersRequest{
+ Reviewers: reviewers,
+ }
+
+ _, resp, err := client.PullRequests.RequestReviewers(ctx, owner, repo, pullNumber, reviewersRequest)
+ if err != nil {
+ return ghErrors.NewGitHubAPIErrorResponse(ctx,
+ "failed to request reviewers",
+ resp,
+ err,
+ ), nil
+ }
+ defer func() {
+ if resp != nil && resp.Body != nil {
+ _ = resp.Body.Close()
+ }
+ }()
+
+ if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK {
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, fmt.Errorf("failed to read response body: %w", err)
+ }
+ return mcp.NewToolResultError(fmt.Sprintf("failed to request reviewers: %s", string(body))), nil
+ }
+ }
+
+ // Get the final state of the PR to return
client, err := getClient(ctx)
if err != nil {
- return nil, fmt.Errorf("failed to get GitHub client: %w", err)
+ return nil, err
}
- pr, resp, err := client.PullRequests.Edit(ctx, owner, repo, pullNumber, update)
+
+ finalPR, resp, err := client.PullRequests.Get(ctx, owner, repo, pullNumber)
if err != nil {
- return ghErrors.NewGitHubAPIErrorResponse(ctx,
- "failed to update pull request",
- resp,
- err,
- ), nil
+ return ghErrors.NewGitHubAPIErrorResponse(ctx, "Failed to get pull request", resp, err), nil
}
- defer func() { _ = resp.Body.Close() }()
-
- if resp.StatusCode != http.StatusOK {
- body, err := io.ReadAll(resp.Body)
- if err != nil {
- return nil, fmt.Errorf("failed to read response body: %w", err)
+ defer func() {
+ if resp != nil && resp.Body != nil {
+ _ = resp.Body.Close()
}
- return mcp.NewToolResultError(fmt.Sprintf("failed to update pull request: %s", string(body))), nil
+ }()
+
+ // Return minimal response with just essential information
+ minimalResponse := MinimalResponse{
+ URL: finalPR.GetHTMLURL(),
}
- r, err := json.Marshal(pr)
+ r, err := json.Marshal(minimalResponse)
if err != nil {
- return nil, fmt.Errorf("failed to marshal response: %w", err)
+ return mcp.NewToolResultError(fmt.Sprintf("Failed to marshal response: %v", err)), nil
}
return mcp.NewToolResultText(string(r)), nil
@@ -403,8 +559,8 @@ func ListPullRequests(getClient GetClientFn, t translations.TranslationHelperFun
Sort: sort,
Direction: direction,
ListOptions: github.ListOptions{
- PerPage: pagination.perPage,
- Page: pagination.page,
+ PerPage: pagination.PerPage,
+ Page: pagination.Page,
},
}
@@ -545,10 +701,10 @@ func SearchPullRequests(getClient GetClientFn, t translations.TranslationHelperF
mcp.Description("Search query using GitHub pull request search syntax"),
),
mcp.WithString("owner",
- mcp.Description("Optional repository owner. If provided with repo, only notifications for this repository are listed."),
+ mcp.Description("Optional repository owner. If provided with repo, only pull requests for this repository are listed."),
),
mcp.WithString("repo",
- mcp.Description("Optional repository name. If provided with owner, only notifications for this repository are listed."),
+ mcp.Description("Optional repository name. If provided with owner, only pull requests for this repository are listed."),
),
mcp.WithString("sort",
mcp.Description("Sort field by number of matches of categories, defaults to best match"),
@@ -622,8 +778,8 @@ func GetPullRequestFiles(getClient GetClientFn, t translations.TranslationHelper
return nil, fmt.Errorf("failed to get GitHub client: %w", err)
}
opts := &github.ListOptions{
- PerPage: pagination.perPage,
- Page: pagination.page,
+ PerPage: pagination.PerPage,
+ Page: pagination.Page,
}
files, resp, err := client.PullRequests.ListFiles(ctx, owner, repo, pullNumber, opts)
if err != nil {
diff --git a/pkg/github/pullrequests_test.go b/pkg/github/pullrequests_test.go
index 42fd5bf03..ea2df97f4 100644
--- a/pkg/github/pullrequests_test.go
+++ b/pkg/github/pullrequests_test.go
@@ -10,7 +10,7 @@ import (
"github.com/github/github-mcp-server/internal/githubv4mock"
"github.com/github/github-mcp-server/internal/toolsnaps"
"github.com/github/github-mcp-server/pkg/translations"
- "github.com/google/go-github/v73/github"
+ "github.com/google/go-github/v74/github"
"github.com/shurcooL/githubv4"
"github.com/migueleliasweb/go-github-mock/src/mock"
@@ -137,7 +137,7 @@ func Test_GetPullRequest(t *testing.T) {
func Test_UpdatePullRequest(t *testing.T) {
// Verify tool definition once
mockClient := github.NewClient(nil)
- tool, _ := UpdatePullRequest(stubGetClientFn(mockClient), translations.NullTranslationHelper)
+ tool, _ := UpdatePullRequest(stubGetClientFn(mockClient), stubGetGQLClientFn(githubv4.NewClient(nil)), translations.NullTranslationHelper)
require.NoError(t, toolsnaps.Test(tool.Name, tool))
assert.Equal(t, "update_pull_request", tool.Name)
@@ -145,11 +145,13 @@ func Test_UpdatePullRequest(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, "draft")
assert.Contains(t, tool.InputSchema.Properties, "title")
assert.Contains(t, tool.InputSchema.Properties, "body")
assert.Contains(t, tool.InputSchema.Properties, "state")
assert.Contains(t, tool.InputSchema.Properties, "base")
assert.Contains(t, tool.InputSchema.Properties, "maintainer_can_modify")
+ assert.Contains(t, tool.InputSchema.Properties, "reviewers")
assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "pullNumber"})
// Setup mock PR for success case
@@ -160,6 +162,7 @@ func Test_UpdatePullRequest(t *testing.T) {
HTMLURL: github.Ptr("https://github.com/owner/repo/pull/42"),
Body: github.Ptr("Updated test PR body."),
MaintainerCanModify: github.Ptr(false),
+ Draft: github.Ptr(false),
Base: &github.PullRequestBranch{
Ref: github.Ptr("develop"),
},
@@ -171,6 +174,17 @@ func Test_UpdatePullRequest(t *testing.T) {
State: github.Ptr("closed"), // State updated
}
+ // Mock PR for when there are no updates but we still need a response
+ mockPRWithReviewers := &github.PullRequest{
+ Number: github.Ptr(42),
+ Title: github.Ptr("Test PR"),
+ State: github.Ptr("open"),
+ RequestedReviewers: []*github.User{
+ {Login: github.Ptr("reviewer1")},
+ {Login: github.Ptr("reviewer2")},
+ },
+ }
+
tests := []struct {
name string
mockedClient *http.Client
@@ -194,6 +208,10 @@ func Test_UpdatePullRequest(t *testing.T) {
mockResponse(t, http.StatusOK, mockUpdatedPR),
),
),
+ mock.WithRequestMatch(
+ mock.GetReposPullsByOwnerByRepoByPullNumber,
+ mockUpdatedPR,
+ ),
),
requestArgs: map[string]interface{}{
"owner": "owner",
@@ -218,6 +236,10 @@ func Test_UpdatePullRequest(t *testing.T) {
mockResponse(t, http.StatusOK, mockClosedPR),
),
),
+ mock.WithRequestMatch(
+ mock.GetReposPullsByOwnerByRepoByPullNumber,
+ mockClosedPR,
+ ),
),
requestArgs: map[string]interface{}{
"owner": "owner",
@@ -228,6 +250,53 @@ func Test_UpdatePullRequest(t *testing.T) {
expectError: false,
expectedPR: mockClosedPR,
},
+ {
+ name: "successful PR update with reviewers",
+ mockedClient: mock.NewMockedHTTPClient(
+ // Mock for RequestReviewers call, returning the PR with reviewers
+ mock.WithRequestMatch(
+ mock.PostReposPullsRequestedReviewersByOwnerByRepoByPullNumber,
+ mockPRWithReviewers,
+ ),
+ mock.WithRequestMatch(
+ mock.GetReposPullsByOwnerByRepoByPullNumber,
+ mockPRWithReviewers,
+ ),
+ ),
+ requestArgs: map[string]interface{}{
+ "owner": "owner",
+ "repo": "repo",
+ "pullNumber": float64(42),
+ "reviewers": []interface{}{"reviewer1", "reviewer2"},
+ },
+ expectError: false,
+ expectedPR: mockPRWithReviewers,
+ },
+ {
+ name: "successful PR update (title only)",
+ mockedClient: mock.NewMockedHTTPClient(
+ mock.WithRequestMatchHandler(
+ mock.PatchReposPullsByOwnerByRepoByPullNumber,
+ expectRequestBody(t, map[string]interface{}{
+ "title": "Updated Test PR Title",
+ }).andThen(
+ mockResponse(t, http.StatusOK, mockUpdatedPR),
+ ),
+ ),
+ mock.WithRequestMatch(
+ mock.GetReposPullsByOwnerByRepoByPullNumber,
+ mockUpdatedPR,
+ ),
+ ),
+ requestArgs: map[string]interface{}{
+ "owner": "owner",
+ "repo": "repo",
+ "pullNumber": float64(42),
+ "title": "Updated Test PR Title",
+ },
+ expectError: false,
+ expectedPR: mockUpdatedPR,
+ },
{
name: "no update parameters provided",
mockedClient: mock.NewMockedHTTPClient(), // No API call expected
@@ -260,13 +329,34 @@ func Test_UpdatePullRequest(t *testing.T) {
expectError: true,
expectedErrMsg: "failed to update pull request",
},
+ {
+ name: "request reviewers fails",
+ mockedClient: mock.NewMockedHTTPClient(
+ // Then reviewer request fails
+ mock.WithRequestMatchHandler(
+ mock.PostReposPullsRequestedReviewersByOwnerByRepoByPullNumber,
+ http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(http.StatusUnprocessableEntity)
+ _, _ = w.Write([]byte(`{"message": "Invalid reviewers"}`))
+ }),
+ ),
+ ),
+ requestArgs: map[string]interface{}{
+ "owner": "owner",
+ "repo": "repo",
+ "pullNumber": float64(42),
+ "reviewers": []interface{}{"invalid-user"},
+ },
+ expectError: true,
+ expectedErrMsg: "failed to request reviewers",
+ },
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
// Setup client with mock
client := github.NewClient(tc.mockedClient)
- _, handler := UpdatePullRequest(stubGetClientFn(client), translations.NullTranslationHelper)
+ _, handler := UpdatePullRequest(stubGetClientFn(client), stubGetGQLClientFn(githubv4.NewClient(nil)), translations.NullTranslationHelper)
// Create call request
request := createMCPRequest(tc.requestArgs)
@@ -291,27 +381,193 @@ func Test_UpdatePullRequest(t *testing.T) {
// Parse the result and get the text content
textContent := getTextResult(t, result)
- // Unmarshal and verify the successful result
- var returnedPR github.PullRequest
- err = json.Unmarshal([]byte(textContent.Text), &returnedPR)
+ // Unmarshal and verify the minimal result
+ var updateResp MinimalResponse
+ err = json.Unmarshal([]byte(textContent.Text), &updateResp)
require.NoError(t, err)
- assert.Equal(t, *tc.expectedPR.Number, *returnedPR.Number)
- if tc.expectedPR.Title != nil {
- assert.Equal(t, *tc.expectedPR.Title, *returnedPR.Title)
- }
- if tc.expectedPR.Body != nil {
- assert.Equal(t, *tc.expectedPR.Body, *returnedPR.Body)
- }
- if tc.expectedPR.State != nil {
- assert.Equal(t, *tc.expectedPR.State, *returnedPR.State)
- }
- if tc.expectedPR.Base != nil && tc.expectedPR.Base.Ref != nil {
- assert.NotNil(t, returnedPR.Base)
- assert.Equal(t, *tc.expectedPR.Base.Ref, *returnedPR.Base.Ref)
- }
- if tc.expectedPR.MaintainerCanModify != nil {
- assert.Equal(t, *tc.expectedPR.MaintainerCanModify, *returnedPR.MaintainerCanModify)
+ assert.Equal(t, tc.expectedPR.GetHTMLURL(), updateResp.URL)
+ })
+ }
+}
+
+func Test_UpdatePullRequest_Draft(t *testing.T) {
+ // Setup mock PR for success case
+ mockUpdatedPR := &github.PullRequest{
+ Number: github.Ptr(42),
+ Title: github.Ptr("Test PR Title"),
+ State: github.Ptr("open"),
+ HTMLURL: github.Ptr("https://github.com/owner/repo/pull/42"),
+ Body: github.Ptr("Test PR body."),
+ MaintainerCanModify: github.Ptr(false),
+ Draft: github.Ptr(false), // Updated to ready for review
+ Base: &github.PullRequestBranch{
+ Ref: github.Ptr("main"),
+ },
+ }
+
+ tests := []struct {
+ name string
+ mockedClient *http.Client
+ requestArgs map[string]interface{}
+ expectError bool
+ expectedPR *github.PullRequest
+ expectedErrMsg string
+ }{
+ {
+ name: "successful draft update to ready for review",
+ mockedClient: githubv4mock.NewMockedHTTPClient(
+ githubv4mock.NewQueryMatcher(
+ struct {
+ Repository struct {
+ PullRequest struct {
+ ID githubv4.ID
+ IsDraft githubv4.Boolean
+ } `graphql:"pullRequest(number: $prNum)"`
+ } `graphql:"repository(owner: $owner, name: $repo)"`
+ }{},
+ map[string]any{
+ "owner": githubv4.String("owner"),
+ "repo": githubv4.String("repo"),
+ "prNum": githubv4.Int(42),
+ },
+ githubv4mock.DataResponse(map[string]any{
+ "repository": map[string]any{
+ "pullRequest": map[string]any{
+ "id": "PR_kwDOA0xdyM50BPaO",
+ "isDraft": true, // Current state is draft
+ },
+ },
+ }),
+ ),
+ githubv4mock.NewMutationMatcher(
+ struct {
+ MarkPullRequestReadyForReview struct {
+ PullRequest struct {
+ ID githubv4.ID
+ IsDraft githubv4.Boolean
+ }
+ } `graphql:"markPullRequestReadyForReview(input: $input)"`
+ }{},
+ githubv4.MarkPullRequestReadyForReviewInput{
+ PullRequestID: "PR_kwDOA0xdyM50BPaO",
+ },
+ nil,
+ githubv4mock.DataResponse(map[string]any{
+ "markPullRequestReadyForReview": map[string]any{
+ "pullRequest": map[string]any{
+ "id": "PR_kwDOA0xdyM50BPaO",
+ "isDraft": false,
+ },
+ },
+ }),
+ ),
+ ),
+ requestArgs: map[string]interface{}{
+ "owner": "owner",
+ "repo": "repo",
+ "pullNumber": float64(42),
+ "draft": false,
+ },
+ expectError: false,
+ expectedPR: mockUpdatedPR,
+ },
+ {
+ name: "successful convert pull request to draft",
+ mockedClient: githubv4mock.NewMockedHTTPClient(
+ githubv4mock.NewQueryMatcher(
+ struct {
+ Repository struct {
+ PullRequest struct {
+ ID githubv4.ID
+ IsDraft githubv4.Boolean
+ } `graphql:"pullRequest(number: $prNum)"`
+ } `graphql:"repository(owner: $owner, name: $repo)"`
+ }{},
+ map[string]any{
+ "owner": githubv4.String("owner"),
+ "repo": githubv4.String("repo"),
+ "prNum": githubv4.Int(42),
+ },
+ githubv4mock.DataResponse(map[string]any{
+ "repository": map[string]any{
+ "pullRequest": map[string]any{
+ "id": "PR_kwDOA0xdyM50BPaO",
+ "isDraft": false, // Current state is draft
+ },
+ },
+ }),
+ ),
+ githubv4mock.NewMutationMatcher(
+ struct {
+ ConvertPullRequestToDraft struct {
+ PullRequest struct {
+ ID githubv4.ID
+ IsDraft githubv4.Boolean
+ }
+ } `graphql:"convertPullRequestToDraft(input: $input)"`
+ }{},
+ githubv4.ConvertPullRequestToDraftInput{
+ PullRequestID: "PR_kwDOA0xdyM50BPaO",
+ },
+ nil,
+ githubv4mock.DataResponse(map[string]any{
+ "convertPullRequestToDraft": map[string]any{
+ "pullRequest": map[string]any{
+ "id": "PR_kwDOA0xdyM50BPaO",
+ "isDraft": true,
+ },
+ },
+ }),
+ ),
+ ),
+ requestArgs: map[string]interface{}{
+ "owner": "owner",
+ "repo": "repo",
+ "pullNumber": float64(42),
+ "draft": true,
+ },
+ expectError: false,
+ expectedPR: mockUpdatedPR,
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ // For draft-only tests, we need to mock both GraphQL and the final REST GET call
+ restClient := github.NewClient(mock.NewMockedHTTPClient(
+ mock.WithRequestMatch(
+ mock.GetReposPullsByOwnerByRepoByPullNumber,
+ mockUpdatedPR,
+ ),
+ ))
+ gqlClient := githubv4.NewClient(tc.mockedClient)
+
+ _, handler := UpdatePullRequest(stubGetClientFn(restClient), stubGetGQLClientFn(gqlClient), translations.NullTranslationHelper)
+
+ request := createMCPRequest(tc.requestArgs)
+
+ result, err := handler(context.Background(), request)
+
+ if tc.expectError || tc.expectedErrMsg != "" {
+ require.NoError(t, err)
+ require.True(t, result.IsError)
+ errorContent := getErrorResult(t, result)
+ if tc.expectedErrMsg != "" {
+ assert.Contains(t, errorContent.Text, tc.expectedErrMsg)
+ }
+ return
}
+
+ require.NoError(t, err)
+ require.False(t, result.IsError)
+
+ textContent := getTextResult(t, result)
+
+ // Unmarshal and verify the minimal result
+ var updateResp MinimalResponse
+ err = json.Unmarshal([]byte(textContent.Text), &updateResp)
+ require.NoError(t, err)
+ assert.Equal(t, tc.expectedPR.GetHTMLURL(), updateResp.URL)
})
}
}
@@ -738,6 +994,77 @@ func Test_SearchPullRequests(t *testing.T) {
expectError: false,
expectedResult: mockSearchResult,
},
+ {
+ name: "query with existing is:pr filter - no duplication",
+ mockedClient: mock.NewMockedHTTPClient(
+ mock.WithRequestMatchHandler(
+ mock.GetSearchIssues,
+ expectQueryParams(
+ t,
+ map[string]string{
+ "q": "is:pr repo:github/github-mcp-server is:open draft:false",
+ "page": "1",
+ "per_page": "30",
+ },
+ ).andThen(
+ mockResponse(t, http.StatusOK, mockSearchResult),
+ ),
+ ),
+ ),
+ requestArgs: map[string]interface{}{
+ "query": "is:pr repo:github/github-mcp-server is:open draft:false",
+ },
+ expectError: false,
+ expectedResult: mockSearchResult,
+ },
+ {
+ name: "query with existing repo: filter and conflicting owner/repo params - uses query filter",
+ mockedClient: mock.NewMockedHTTPClient(
+ mock.WithRequestMatchHandler(
+ mock.GetSearchIssues,
+ expectQueryParams(
+ t,
+ map[string]string{
+ "q": "is:pr repo:github/github-mcp-server author:octocat",
+ "page": "1",
+ "per_page": "30",
+ },
+ ).andThen(
+ mockResponse(t, http.StatusOK, mockSearchResult),
+ ),
+ ),
+ ),
+ requestArgs: map[string]interface{}{
+ "query": "repo:github/github-mcp-server author:octocat",
+ "owner": "different-owner",
+ "repo": "different-repo",
+ },
+ expectError: false,
+ expectedResult: mockSearchResult,
+ },
+ {
+ name: "complex query with existing is:pr filter and OR operators",
+ mockedClient: mock.NewMockedHTTPClient(
+ mock.WithRequestMatchHandler(
+ mock.GetSearchIssues,
+ expectQueryParams(
+ 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",
+ },
+ ).andThen(
+ mockResponse(t, http.StatusOK, mockSearchResult),
+ ),
+ ),
+ ),
+ requestArgs: map[string]interface{}{
+ "query": "is:pr repo:github/github-mcp-server (label:bug OR label:enhancement OR label:feature)",
+ },
+ expectError: false,
+ expectedResult: mockSearchResult,
+ },
{
name: "search pull requests fails",
mockedClient: mock.NewMockedHTTPClient(
@@ -1625,18 +1952,11 @@ func Test_CreatePullRequest(t *testing.T) {
// Parse the result and get the text content if no error
textContent := getTextResult(t, result)
- // Unmarshal and verify the result
- var returnedPR github.PullRequest
+ // Unmarshal and verify the minimal result
+ var returnedPR MinimalResponse
err = json.Unmarshal([]byte(textContent.Text), &returnedPR)
require.NoError(t, err)
- assert.Equal(t, *tc.expectedPR.Number, *returnedPR.Number)
- assert.Equal(t, *tc.expectedPR.Title, *returnedPR.Title)
- assert.Equal(t, *tc.expectedPR.State, *returnedPR.State)
- assert.Equal(t, *tc.expectedPR.HTMLURL, *returnedPR.HTMLURL)
- assert.Equal(t, *tc.expectedPR.Head.SHA, *returnedPR.Head.SHA)
- assert.Equal(t, *tc.expectedPR.Base.Ref, *returnedPR.Base.Ref)
- assert.Equal(t, *tc.expectedPR.Body, *returnedPR.Body)
- assert.Equal(t, *tc.expectedPR.User.Login, *returnedPR.User.Login)
+ assert.Equal(t, tc.expectedPR.GetHTMLURL(), returnedPR.URL)
})
}
}
diff --git a/pkg/github/repositories.go b/pkg/github/repositories.go
index 2e56c8644..cef227ba5 100644
--- a/pkg/github/repositories.go
+++ b/pkg/github/repositories.go
@@ -13,7 +13,7 @@ import (
ghErrors "github.com/github/github-mcp-server/pkg/errors"
"github.com/github/github-mcp-server/pkg/raw"
"github.com/github/github-mcp-server/pkg/translations"
- "github.com/google/go-github/v73/github"
+ "github.com/google/go-github/v74/github"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
)
@@ -37,6 +37,10 @@ func GetCommit(getClient GetClientFn, t translations.TranslationHelperFunc) (too
mcp.Required(),
mcp.Description("Commit SHA, branch name, or tag name"),
),
+ mcp.WithBoolean("include_diff",
+ mcp.Description("Whether to include file diffs and stats in the response. Default is true."),
+ mcp.DefaultBool(true),
+ ),
WithPagination(),
),
func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
@@ -52,14 +56,18 @@ func GetCommit(getClient GetClientFn, t translations.TranslationHelperFunc) (too
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
+ includeDiff, err := OptionalBoolParamWithDefault(request, "include_diff", true)
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
pagination, err := OptionalPaginationParams(request)
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
opts := &github.ListOptions{
- Page: pagination.page,
- PerPage: pagination.perPage,
+ Page: pagination.Page,
+ PerPage: pagination.PerPage,
}
client, err := getClient(ctx)
@@ -84,7 +92,10 @@ func GetCommit(getClient GetClientFn, t translations.TranslationHelperFunc) (too
return mcp.NewToolResultError(fmt.Sprintf("failed to get commit: %s", string(body))), nil
}
- r, err := json.Marshal(commit)
+ // Convert to minimal commit
+ minimalCommit := convertToMinimalCommit(commit, includeDiff)
+
+ r, err := json.Marshal(minimalCommit)
if err != nil {
return nil, fmt.Errorf("failed to marshal response: %w", err)
}
@@ -139,7 +150,7 @@ func ListCommits(getClient GetClientFn, t translations.TranslationHelperFunc) (t
return mcp.NewToolResultError(err.Error()), nil
}
// Set default perPage to 30 if not provided
- perPage := pagination.perPage
+ perPage := pagination.PerPage
if perPage == 0 {
perPage = 30
}
@@ -147,7 +158,7 @@ func ListCommits(getClient GetClientFn, t translations.TranslationHelperFunc) (t
SHA: sha,
Author: author,
ListOptions: github.ListOptions{
- Page: pagination.page,
+ Page: pagination.Page,
PerPage: perPage,
},
}
@@ -174,7 +185,13 @@ func ListCommits(getClient GetClientFn, t translations.TranslationHelperFunc) (t
return mcp.NewToolResultError(fmt.Sprintf("failed to list commits: %s", string(body))), nil
}
- r, err := json.Marshal(commits)
+ // Convert to minimal commits
+ minimalCommits := make([]MinimalCommit, len(commits))
+ for i, commit := range commits {
+ minimalCommits[i] = convertToMinimalCommit(commit, false)
+ }
+
+ r, err := json.Marshal(minimalCommits)
if err != nil {
return nil, fmt.Errorf("failed to marshal response: %w", err)
}
@@ -217,8 +234,8 @@ func ListBranches(getClient GetClientFn, t translations.TranslationHelperFunc) (
opts := &github.BranchListOptions{
ListOptions: github.ListOptions{
- Page: pagination.page,
- PerPage: pagination.perPage,
+ Page: pagination.Page,
+ PerPage: pagination.PerPage,
},
}
@@ -245,7 +262,13 @@ func ListBranches(getClient GetClientFn, t translations.TranslationHelperFunc) (
return mcp.NewToolResultError(fmt.Sprintf("failed to list branches: %s", string(body))), nil
}
- r, err := json.Marshal(branches)
+ // Convert to minimal branches
+ minimalBranches := make([]MinimalBranch, 0, len(branches))
+ for _, branch := range branches {
+ minimalBranches = append(minimalBranches, convertToMinimalBranch(branch))
+ }
+
+ r, err := json.Marshal(minimalBranches)
if err != nil {
return nil, fmt.Errorf("failed to marshal response: %w", err)
}
@@ -370,7 +393,7 @@ func CreateOrUpdateFile(getClient GetClientFn, t translations.TranslationHelperF
// CreateRepository creates a tool to create a new GitHub repository.
func CreateRepository(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
return mcp.NewTool("create_repository",
- mcp.WithDescription(t("TOOL_CREATE_REPOSITORY_DESCRIPTION", "Create a new GitHub repository in your account")),
+ mcp.WithDescription(t("TOOL_CREATE_REPOSITORY_DESCRIPTION", "Create a new GitHub repository in your account or specified organization")),
mcp.WithToolAnnotation(mcp.ToolAnnotation{
Title: t("TOOL_CREATE_REPOSITORY_USER_TITLE", "Create repository"),
ReadOnlyHint: ToBoolPtr(false),
@@ -382,6 +405,9 @@ func CreateRepository(getClient GetClientFn, t translations.TranslationHelperFun
mcp.WithString("description",
mcp.Description("Repository description"),
),
+ mcp.WithString("organization",
+ mcp.Description("Organization to create the repository in (omit to create in your personal account)"),
+ ),
mcp.WithBoolean("private",
mcp.Description("Whether repo should be private"),
),
@@ -398,6 +424,10 @@ func CreateRepository(getClient GetClientFn, t translations.TranslationHelperFun
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
+ organization, err := OptionalParam[string](request, "organization")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
private, err := OptionalParam[bool](request, "private")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
@@ -418,7 +448,7 @@ func CreateRepository(getClient GetClientFn, t translations.TranslationHelperFun
if err != nil {
return nil, fmt.Errorf("failed to get GitHub client: %w", err)
}
- createdRepo, resp, err := client.Repositories.Create(ctx, "", repo)
+ createdRepo, resp, err := client.Repositories.Create(ctx, organization, repo)
if err != nil {
return ghErrors.NewGitHubAPIErrorResponse(ctx,
"failed to create repository",
@@ -436,7 +466,12 @@ func CreateRepository(getClient GetClientFn, t translations.TranslationHelperFun
return mcp.NewToolResultError(fmt.Sprintf("failed to create repository: %s", string(body))), nil
}
- r, err := json.Marshal(createdRepo)
+ // Return minimal response with just essential information
+ minimalResponse := MinimalResponse{
+ URL: createdRepo.GetHTMLURL(),
+ }
+
+ r, err := json.Marshal(minimalResponse)
if err != nil {
return nil, fmt.Errorf("failed to marshal response: %w", err)
}
@@ -707,7 +742,12 @@ func ForkRepository(getClient GetClientFn, t translations.TranslationHelperFunc)
return mcp.NewToolResultError(fmt.Sprintf("failed to fork repository: %s", string(body))), nil
}
- r, err := json.Marshal(forkedRepo)
+ // Return minimal response with just essential information
+ minimalResponse := MinimalResponse{
+ URL: forkedRepo.GetHTMLURL(),
+ }
+
+ r, err := json.Marshal(minimalResponse)
if err != nil {
return nil, fmt.Errorf("failed to marshal response: %w", err)
}
@@ -1198,8 +1238,8 @@ func ListTags(getClient GetClientFn, t translations.TranslationHelperFunc) (tool
}
opts := &github.ListOptions{
- Page: pagination.page,
- PerPage: pagination.perPage,
+ Page: pagination.Page,
+ PerPage: pagination.PerPage,
}
client, err := getClient(ctx)
@@ -1321,6 +1361,192 @@ func GetTag(getClient GetClientFn, t translations.TranslationHelperFunc) (tool m
}
}
+// ListReleases creates a tool to list releases in a GitHub repository.
+func ListReleases(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
+ return mcp.NewTool("list_releases",
+ mcp.WithDescription(t("TOOL_LIST_RELEASES_DESCRIPTION", "List releases in a GitHub repository")),
+ mcp.WithToolAnnotation(mcp.ToolAnnotation{
+ Title: t("TOOL_LIST_RELEASES_USER_TITLE", "List releases"),
+ ReadOnlyHint: ToBoolPtr(true),
+ }),
+ mcp.WithString("owner",
+ mcp.Required(),
+ mcp.Description("Repository owner"),
+ ),
+ mcp.WithString("repo",
+ mcp.Required(),
+ mcp.Description("Repository name"),
+ ),
+ WithPagination(),
+ ),
+ func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
+ owner, err := RequiredParam[string](request, "owner")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+ repo, err := RequiredParam[string](request, "repo")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+ pagination, err := OptionalPaginationParams(request)
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+
+ opts := &github.ListOptions{
+ Page: pagination.Page,
+ PerPage: pagination.PerPage,
+ }
+
+ client, err := getClient(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get GitHub client: %w", err)
+ }
+
+ releases, resp, err := client.Repositories.ListReleases(ctx, owner, repo, opts)
+ if err != nil {
+ return nil, fmt.Errorf("failed to list releases: %w", err)
+ }
+ defer func() { _ = resp.Body.Close() }()
+
+ if resp.StatusCode != http.StatusOK {
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, fmt.Errorf("failed to read response body: %w", err)
+ }
+ return 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
+ }
+}
+
+// GetLatestRelease creates a tool to get the latest release in a GitHub repository.
+func GetLatestRelease(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
+ return mcp.NewTool("get_latest_release",
+ mcp.WithDescription(t("TOOL_GET_LATEST_RELEASE_DESCRIPTION", "Get the latest release in a GitHub repository")),
+ mcp.WithToolAnnotation(mcp.ToolAnnotation{
+ Title: t("TOOL_GET_LATEST_RELEASE_USER_TITLE", "Get latest release"),
+ ReadOnlyHint: ToBoolPtr(true),
+ }),
+ mcp.WithString("owner",
+ mcp.Required(),
+ mcp.Description("Repository owner"),
+ ),
+ mcp.WithString("repo",
+ mcp.Required(),
+ mcp.Description("Repository name"),
+ ),
+ ),
+ func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
+ owner, err := RequiredParam[string](request, "owner")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+ repo, err := RequiredParam[string](request, "repo")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+
+ client, err := getClient(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get GitHub client: %w", err)
+ }
+
+ release, resp, err := client.Repositories.GetLatestRelease(ctx, owner, repo)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get latest release: %w", err)
+ }
+ defer func() { _ = resp.Body.Close() }()
+
+ if resp.StatusCode != http.StatusOK {
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, fmt.Errorf("failed to read response body: %w", err)
+ }
+ return mcp.NewToolResultError(fmt.Sprintf("failed to get latest release: %s", string(body))), nil
+ }
+
+ r, err := json.Marshal(release)
+ if err != nil {
+ return nil, fmt.Errorf("failed to marshal response: %w", err)
+ }
+
+ return mcp.NewToolResultText(string(r)), nil
+ }
+}
+
+func GetReleaseByTag(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
+ return mcp.NewTool("get_release_by_tag",
+ mcp.WithDescription(t("TOOL_GET_RELEASE_BY_TAG_DESCRIPTION", "Get a specific release by its tag name in a GitHub repository")),
+ mcp.WithToolAnnotation(mcp.ToolAnnotation{
+ Title: t("TOOL_GET_RELEASE_BY_TAG_USER_TITLE", "Get a release by tag name"),
+ ReadOnlyHint: ToBoolPtr(true),
+ }),
+ mcp.WithString("owner",
+ mcp.Required(),
+ mcp.Description("Repository owner"),
+ ),
+ mcp.WithString("repo",
+ mcp.Required(),
+ mcp.Description("Repository name"),
+ ),
+ mcp.WithString("tag",
+ mcp.Required(),
+ mcp.Description("Tag name (e.g., 'v1.0.0')"),
+ ),
+ ),
+ func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
+ owner, err := RequiredParam[string](request, "owner")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+ repo, err := RequiredParam[string](request, "repo")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+ tag, err := RequiredParam[string](request, "tag")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+
+ client, err := getClient(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get GitHub client: %w", err)
+ }
+
+ release, resp, err := client.Repositories.GetReleaseByTag(ctx, owner, repo, tag)
+ if err != nil {
+ return ghErrors.NewGitHubAPIErrorResponse(ctx,
+ fmt.Sprintf("failed to get release by tag: %s", tag),
+ resp,
+ err,
+ ), nil
+ }
+ defer func() { _ = resp.Body.Close() }()
+
+ if resp.StatusCode != http.StatusOK {
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, fmt.Errorf("failed to read response body: %w", err)
+ }
+ return mcp.NewToolResultError(fmt.Sprintf("failed to get release by tag: %s", string(body))), nil
+ }
+
+ r, err := json.Marshal(release)
+ if err != nil {
+ return nil, fmt.Errorf("failed to marshal response: %w", err)
+ }
+
+ return mcp.NewToolResultText(string(r)), nil
+ }
+}
+
// filterPaths filters the entries in a GitHub tree to find paths that
// match the given suffix.
// maxResults limits the number of results returned to first maxResults entries,
@@ -1358,36 +1584,100 @@ func filterPaths(entries []*github.TreeEntry, path string, maxResults int) []str
return matchedPaths
}
-// resolveGitReference resolves git references with the following logic:
-// 1. If SHA is provided, it takes precedence
-// 2. If neither is provided, use the default branch as ref
-// 3. Get commit SHA from the ref
-// Refs can look like `refs/tags/{tag}`, `refs/heads/{branch}` or `refs/pull/{pr_number}/head`
-// The function returns the resolved ref, commit SHA and any error.
+// resolveGitReference takes a user-provided ref and sha and resolves them into a
+// definitive commit SHA and its corresponding fully-qualified reference.
+//
+// The resolution logic follows a clear priority:
+//
+// 1. If a specific commit `sha` is provided, it takes precedence and is used directly,
+// and all reference resolution is skipped.
+//
+// 2. If no `sha` is provided, the function resolves the `ref`
+// string into a fully-qualified format (e.g., "refs/heads/main") by trying
+// the following steps in order:
+// a). **Empty Ref:** If `ref` is empty, the repository's default branch is used.
+// b). **Fully-Qualified:** If `ref` already starts with "refs/", it's considered fully
+// qualified and used as-is.
+// c). **Partially-Qualified:** If `ref` starts with "heads/" or "tags/", it is
+// prefixed with "refs/" to make it fully-qualified.
+// d). **Short Name:** Otherwise, the `ref` is treated as a short name. The function
+// first attempts to resolve it as a branch ("refs/heads/["). If that
+// returns a 404 Not Found error, it then attempts to resolve it as a tag
+// ("refs/tags/][").
+//
+// 3. **Final Lookup:** Once a fully-qualified ref is determined, a final API call
+// is made to fetch that reference's definitive commit SHA.
+//
+// Any unexpected (non-404) errors during the resolution process are returned
+// immediately. All API errors are logged with rich context to aid diagnostics.
func resolveGitReference(ctx context.Context, githubClient *github.Client, owner, repo, ref, sha string) (*raw.ContentOpts, error) {
- // 1. If SHA is provided, use it directly
+ // 1) If SHA explicitly provided, it's the highest priority.
if sha != "" {
return &raw.ContentOpts{Ref: "", SHA: sha}, nil
}
- // 2. If neither provided, use the default branch as ref
- if ref == "" {
+ originalRef := ref // Keep original ref for clearer error messages down the line.
+
+ // 2) If no SHA is provided, we try to resolve the ref into a fully-qualified format.
+ var reference *github.Reference
+ var resp *github.Response
+ var err error
+
+ switch {
+ case originalRef == "":
+ // 2a) If ref is empty, determine the default branch.
repoInfo, resp, err := githubClient.Repositories.Get(ctx, owner, repo)
if err != nil {
_, _ = ghErrors.NewGitHubAPIErrorToCtx(ctx, "failed to get repository info", resp, err)
return nil, fmt.Errorf("failed to get repository info: %w", err)
}
ref = fmt.Sprintf("refs/heads/%s", repoInfo.GetDefaultBranch())
+ case strings.HasPrefix(originalRef, "refs/"):
+ // 2b) Already fully qualified. The reference will be fetched at the end.
+ case strings.HasPrefix(originalRef, "heads/") || strings.HasPrefix(originalRef, "tags/"):
+ // 2c) Partially qualified. Make it fully qualified.
+ ref = "refs/" + originalRef
+ default:
+ // 2d) It's a short name, so we try to resolve it to either a branch or a tag.
+ branchRef := "refs/heads/" + originalRef
+ reference, resp, err = githubClient.Git.GetRef(ctx, owner, repo, branchRef)
+
+ if err == nil {
+ ref = branchRef // It's a branch.
+ } else {
+ // The branch lookup failed. Check if it was a 404 Not Found error.
+ ghErr, isGhErr := err.(*github.ErrorResponse)
+ if isGhErr && ghErr.Response.StatusCode == http.StatusNotFound {
+ tagRef := "refs/tags/" + originalRef
+ reference, resp, err = githubClient.Git.GetRef(ctx, owner, repo, tagRef)
+ if err == nil {
+ ref = tagRef // It's a tag.
+ } else {
+ // The tag lookup also failed. Check if it was a 404 Not Found error.
+ ghErr2, isGhErr2 := err.(*github.ErrorResponse)
+ if isGhErr2 && ghErr2.Response.StatusCode == http.StatusNotFound {
+ return nil, fmt.Errorf("could not resolve ref %q as a branch or a tag", originalRef)
+ }
+ // The tag lookup failed for a different reason.
+ _, _ = ghErrors.NewGitHubAPIErrorToCtx(ctx, "failed to get reference (tag)", resp, err)
+ return nil, fmt.Errorf("failed to get reference for tag '%s': %w", originalRef, err)
+ }
+ } else {
+ // The branch lookup failed for a different reason.
+ _, _ = ghErrors.NewGitHubAPIErrorToCtx(ctx, "failed to get reference (branch)", resp, err)
+ return nil, fmt.Errorf("failed to get reference for branch '%s': %w", originalRef, err)
+ }
+ }
}
- // 3. Get the SHA from the ref
- reference, resp, err := githubClient.Git.GetRef(ctx, owner, repo, ref)
- if err != nil {
- _, _ = ghErrors.NewGitHubAPIErrorToCtx(ctx, "failed to get reference", resp, err)
- return nil, fmt.Errorf("failed to get reference: %w", err)
+ if reference == nil {
+ reference, resp, err = githubClient.Git.GetRef(ctx, owner, repo, ref)
+ if err != nil {
+ _, _ = ghErrors.NewGitHubAPIErrorToCtx(ctx, "failed to get final reference", resp, err)
+ return nil, fmt.Errorf("failed to get final reference for %q: %w", ref, err)
+ }
}
- sha = reference.GetObject().GetSHA()
- // Use provided ref, or it will be empty which defaults to the default branch
+ sha = reference.GetObject().GetSHA()
return &raw.ContentOpts{Ref: ref, SHA: sha}, nil
}
diff --git a/pkg/github/repositories_test.go b/pkg/github/repositories_test.go
index 1572a12f4..468d7c29b 100644
--- a/pkg/github/repositories_test.go
+++ b/pkg/github/repositories_test.go
@@ -6,13 +6,14 @@ import (
"encoding/json"
"net/http"
"net/url"
+ "strings"
"testing"
"time"
"github.com/github/github-mcp-server/internal/toolsnaps"
"github.com/github/github-mcp-server/pkg/raw"
"github.com/github/github-mcp-server/pkg/translations"
- "github.com/google/go-github/v73/github"
+ "github.com/google/go-github/v74/github"
"github.com/mark3labs/mcp-go/mcp"
"github.com/migueleliasweb/go-github-mock/src/mock"
"github.com/stretchr/testify/assert"
@@ -736,9 +737,33 @@ func Test_ListCommits(t *testing.T) {
},
},
Author: &github.User{
- Login: github.Ptr("testuser"),
+ Login: github.Ptr("testuser"),
+ ID: github.Ptr(int64(12345)),
+ HTMLURL: github.Ptr("https://github.com/testuser"),
+ AvatarURL: github.Ptr("https://github.com/testuser.png"),
},
HTMLURL: github.Ptr("https://github.com/owner/repo/commit/abc123def456"),
+ Stats: &github.CommitStats{
+ Additions: github.Ptr(10),
+ Deletions: github.Ptr(5),
+ Total: github.Ptr(15),
+ },
+ Files: []*github.CommitFile{
+ {
+ Filename: github.Ptr("src/main.go"),
+ Status: github.Ptr("modified"),
+ Additions: github.Ptr(8),
+ Deletions: github.Ptr(3),
+ Changes: github.Ptr(11),
+ },
+ {
+ Filename: github.Ptr("README.md"),
+ Status: github.Ptr("added"),
+ Additions: github.Ptr(2),
+ Deletions: github.Ptr(2),
+ Changes: github.Ptr(4),
+ },
+ },
},
{
SHA: github.Ptr("def456abc789"),
@@ -751,9 +776,26 @@ func Test_ListCommits(t *testing.T) {
},
},
Author: &github.User{
- Login: github.Ptr("anotheruser"),
+ Login: github.Ptr("anotheruser"),
+ ID: github.Ptr(int64(67890)),
+ HTMLURL: github.Ptr("https://github.com/anotheruser"),
+ AvatarURL: github.Ptr("https://github.com/anotheruser.png"),
},
HTMLURL: github.Ptr("https://github.com/owner/repo/commit/def456abc789"),
+ Stats: &github.CommitStats{
+ Additions: github.Ptr(20),
+ Deletions: github.Ptr(10),
+ Total: github.Ptr(30),
+ },
+ Files: []*github.CommitFile{
+ {
+ Filename: github.Ptr("src/utils.go"),
+ Status: github.Ptr("added"),
+ Additions: github.Ptr(20),
+ Deletions: github.Ptr(10),
+ Changes: github.Ptr(30),
+ },
+ },
},
}
@@ -874,16 +916,23 @@ func Test_ListCommits(t *testing.T) {
textContent := getTextResult(t, result)
// Unmarshal and verify the result
- var returnedCommits []*github.RepositoryCommit
+ var returnedCommits []MinimalCommit
err = json.Unmarshal([]byte(textContent.Text), &returnedCommits)
require.NoError(t, err)
assert.Len(t, returnedCommits, len(tc.expectedCommits))
for i, commit := range returnedCommits {
- assert.Equal(t, *tc.expectedCommits[i].Author, *commit.Author)
- assert.Equal(t, *tc.expectedCommits[i].SHA, *commit.SHA)
- assert.Equal(t, *tc.expectedCommits[i].Commit.Message, *commit.Commit.Message)
- assert.Equal(t, *tc.expectedCommits[i].Author.Login, *commit.Author.Login)
- assert.Equal(t, *tc.expectedCommits[i].HTMLURL, *commit.HTMLURL)
+ assert.Equal(t, tc.expectedCommits[i].GetSHA(), commit.SHA)
+ assert.Equal(t, tc.expectedCommits[i].GetHTMLURL(), commit.HTMLURL)
+ if tc.expectedCommits[i].Commit != nil {
+ assert.Equal(t, tc.expectedCommits[i].Commit.GetMessage(), commit.Commit.Message)
+ }
+ if tc.expectedCommits[i].Author != nil {
+ assert.Equal(t, tc.expectedCommits[i].Author.GetLogin(), commit.Author.Login)
+ }
+
+ // Files and stats are never included in list_commits
+ assert.Nil(t, commit.Files)
+ assert.Nil(t, commit.Stats)
}
})
}
@@ -1066,6 +1115,7 @@ func Test_CreateRepository(t *testing.T) {
assert.NotEmpty(t, tool.Description)
assert.Contains(t, tool.InputSchema.Properties, "name")
assert.Contains(t, tool.InputSchema.Properties, "description")
+ assert.Contains(t, tool.InputSchema.Properties, "organization")
assert.Contains(t, tool.InputSchema.Properties, "private")
assert.Contains(t, tool.InputSchema.Properties, "autoInit")
assert.ElementsMatch(t, tool.InputSchema.Required, []string{"name"})
@@ -1076,7 +1126,6 @@ func Test_CreateRepository(t *testing.T) {
Description: github.Ptr("Test repository"),
Private: github.Ptr(true),
HTMLURL: github.Ptr("https://github.com/testuser/test-repo"),
- CloneURL: github.Ptr("https://github.com/testuser/test-repo.git"),
CreatedAt: &github.Timestamp{Time: time.Now()},
Owner: &github.User{
Login: github.Ptr("testuser"),
@@ -1118,6 +1167,34 @@ func Test_CreateRepository(t *testing.T) {
expectError: false,
expectedRepo: mockRepo,
},
+ {
+ name: "successful repository creation in organization",
+ mockedClient: mock.NewMockedHTTPClient(
+ mock.WithRequestMatchHandler(
+ mock.EndpointPattern{
+ Pattern: "/orgs/testorg/repos",
+ Method: "POST",
+ },
+ expectRequestBody(t, map[string]interface{}{
+ "name": "test-repo",
+ "description": "Test repository",
+ "private": false,
+ "auto_init": true,
+ }).andThen(
+ mockResponse(t, http.StatusCreated, mockRepo),
+ ),
+ ),
+ ),
+ requestArgs: map[string]interface{}{
+ "name": "test-repo",
+ "description": "Test repository",
+ "organization": "testorg",
+ "private": false,
+ "autoInit": true,
+ },
+ expectError: false,
+ expectedRepo: mockRepo,
+ },
{
name: "successful repository creation with minimal parameters",
mockedClient: mock.NewMockedHTTPClient(
@@ -1191,17 +1268,13 @@ func Test_CreateRepository(t *testing.T) {
// Parse the result and get the text content if no error
textContent := getTextResult(t, result)
- // Unmarshal and verify the result
- var returnedRepo github.Repository
+ // Unmarshal and verify the minimal result
+ var returnedRepo MinimalResponse
err = json.Unmarshal([]byte(textContent.Text), &returnedRepo)
assert.NoError(t, err)
// Verify repository details
- assert.Equal(t, *tc.expectedRepo.Name, *returnedRepo.Name)
- assert.Equal(t, *tc.expectedRepo.Description, *returnedRepo.Description)
- assert.Equal(t, *tc.expectedRepo.Private, *returnedRepo.Private)
- assert.Equal(t, *tc.expectedRepo.HTMLURL, *returnedRepo.HTMLURL)
- assert.Equal(t, *tc.expectedRepo.Owner.Login, *returnedRepo.Owner.Login)
+ assert.Equal(t, tc.expectedRepo.GetHTMLURL(), returnedRepo.URL)
})
}
}
@@ -2113,6 +2186,344 @@ func Test_GetTag(t *testing.T) {
}
}
+func Test_ListReleases(t *testing.T) {
+ mockClient := github.NewClient(nil)
+ tool, _ := ListReleases(stubGetClientFn(mockClient), translations.NullTranslationHelper)
+
+ assert.Equal(t, "list_releases", tool.Name)
+ assert.NotEmpty(t, tool.Description)
+ assert.Contains(t, tool.InputSchema.Properties, "owner")
+ assert.Contains(t, tool.InputSchema.Properties, "repo")
+ assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo"})
+
+ mockReleases := []*github.RepositoryRelease{
+ {
+ ID: github.Ptr(int64(1)),
+ TagName: github.Ptr("v1.0.0"),
+ Name: github.Ptr("First Release"),
+ },
+ {
+ ID: github.Ptr(int64(2)),
+ TagName: github.Ptr("v0.9.0"),
+ Name: github.Ptr("Beta Release"),
+ },
+ }
+
+ tests := []struct {
+ name string
+ mockedClient *http.Client
+ requestArgs map[string]interface{}
+ expectError bool
+ expectedResult []*github.RepositoryRelease
+ expectedErrMsg string
+ }{
+ {
+ name: "successful releases list",
+ mockedClient: mock.NewMockedHTTPClient(
+ mock.WithRequestMatch(
+ mock.GetReposReleasesByOwnerByRepo,
+ mockReleases,
+ ),
+ ),
+ requestArgs: map[string]interface{}{
+ "owner": "owner",
+ "repo": "repo",
+ },
+ expectError: false,
+ expectedResult: mockReleases,
+ },
+ {
+ name: "releases list fails",
+ mockedClient: mock.NewMockedHTTPClient(
+ mock.WithRequestMatchHandler(
+ mock.GetReposReleasesByOwnerByRepo,
+ http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(http.StatusNotFound)
+ _, _ = w.Write([]byte(`{"message": "Not Found"}`))
+ }),
+ ),
+ ),
+ requestArgs: map[string]interface{}{
+ "owner": "owner",
+ "repo": "repo",
+ },
+ expectError: true,
+ expectedErrMsg: "failed to list releases",
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ client := github.NewClient(tc.mockedClient)
+ _, handler := ListReleases(stubGetClientFn(client), translations.NullTranslationHelper)
+ request := createMCPRequest(tc.requestArgs)
+ result, err := handler(context.Background(), request)
+
+ if tc.expectError {
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), tc.expectedErrMsg)
+ return
+ }
+
+ require.NoError(t, err)
+ textContent := getTextResult(t, result)
+ var returnedReleases []*github.RepositoryRelease
+ err = json.Unmarshal([]byte(textContent.Text), &returnedReleases)
+ require.NoError(t, err)
+ assert.Len(t, returnedReleases, len(tc.expectedResult))
+ for i, rel := range returnedReleases {
+ assert.Equal(t, *tc.expectedResult[i].TagName, *rel.TagName)
+ }
+ })
+ }
+}
+func Test_GetLatestRelease(t *testing.T) {
+ mockClient := github.NewClient(nil)
+ tool, _ := GetLatestRelease(stubGetClientFn(mockClient), translations.NullTranslationHelper)
+
+ assert.Equal(t, "get_latest_release", tool.Name)
+ assert.NotEmpty(t, tool.Description)
+ assert.Contains(t, tool.InputSchema.Properties, "owner")
+ assert.Contains(t, tool.InputSchema.Properties, "repo")
+ assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo"})
+
+ mockRelease := &github.RepositoryRelease{
+ ID: github.Ptr(int64(1)),
+ TagName: github.Ptr("v1.0.0"),
+ Name: github.Ptr("First Release"),
+ }
+
+ tests := []struct {
+ name string
+ mockedClient *http.Client
+ requestArgs map[string]interface{}
+ expectError bool
+ expectedResult *github.RepositoryRelease
+ expectedErrMsg string
+ }{
+ {
+ name: "successful latest release fetch",
+ mockedClient: mock.NewMockedHTTPClient(
+ mock.WithRequestMatch(
+ mock.GetReposReleasesLatestByOwnerByRepo,
+ mockRelease,
+ ),
+ ),
+ requestArgs: map[string]interface{}{
+ "owner": "owner",
+ "repo": "repo",
+ },
+ expectError: false,
+ expectedResult: mockRelease,
+ },
+ {
+ name: "latest release fetch fails",
+ mockedClient: mock.NewMockedHTTPClient(
+ mock.WithRequestMatchHandler(
+ mock.GetReposReleasesLatestByOwnerByRepo,
+ http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(http.StatusNotFound)
+ _, _ = w.Write([]byte(`{"message": "Not Found"}`))
+ }),
+ ),
+ ),
+ requestArgs: map[string]interface{}{
+ "owner": "owner",
+ "repo": "repo",
+ },
+ expectError: true,
+ expectedErrMsg: "failed to get latest release",
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ client := github.NewClient(tc.mockedClient)
+ _, handler := GetLatestRelease(stubGetClientFn(client), translations.NullTranslationHelper)
+ request := createMCPRequest(tc.requestArgs)
+ result, err := handler(context.Background(), request)
+
+ if tc.expectError {
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), tc.expectedErrMsg)
+ return
+ }
+
+ require.NoError(t, err)
+ textContent := getTextResult(t, result)
+ var returnedRelease github.RepositoryRelease
+ err = json.Unmarshal([]byte(textContent.Text), &returnedRelease)
+ require.NoError(t, err)
+ assert.Equal(t, *tc.expectedResult.TagName, *returnedRelease.TagName)
+ })
+ }
+}
+
+func Test_GetReleaseByTag(t *testing.T) {
+ mockClient := github.NewClient(nil)
+ tool, _ := GetReleaseByTag(stubGetClientFn(mockClient), translations.NullTranslationHelper)
+ require.NoError(t, toolsnaps.Test(tool.Name, tool))
+
+ assert.Equal(t, "get_release_by_tag", tool.Name)
+ 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, "tag")
+ assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "tag"})
+
+ mockRelease := &github.RepositoryRelease{
+ ID: github.Ptr(int64(1)),
+ TagName: github.Ptr("v1.0.0"),
+ Name: github.Ptr("Release v1.0.0"),
+ Body: github.Ptr("This is the first stable release."),
+ Assets: []*github.ReleaseAsset{
+ {
+ ID: github.Ptr(int64(1)),
+ Name: github.Ptr("release-v1.0.0.tar.gz"),
+ },
+ },
+ }
+
+ tests := []struct {
+ name string
+ mockedClient *http.Client
+ requestArgs map[string]interface{}
+ expectError bool
+ expectedResult *github.RepositoryRelease
+ expectedErrMsg string
+ }{
+ {
+ name: "successful release by tag fetch",
+ mockedClient: mock.NewMockedHTTPClient(
+ mock.WithRequestMatch(
+ mock.GetReposReleasesTagsByOwnerByRepoByTag,
+ mockRelease,
+ ),
+ ),
+ requestArgs: map[string]interface{}{
+ "owner": "owner",
+ "repo": "repo",
+ "tag": "v1.0.0",
+ },
+ expectError: false,
+ expectedResult: mockRelease,
+ },
+ {
+ name: "missing owner parameter",
+ mockedClient: mock.NewMockedHTTPClient(),
+ requestArgs: map[string]interface{}{
+ "repo": "repo",
+ "tag": "v1.0.0",
+ },
+ expectError: false, // Returns tool error, not Go error
+ expectedErrMsg: "missing required parameter: owner",
+ },
+ {
+ name: "missing repo parameter",
+ mockedClient: mock.NewMockedHTTPClient(),
+ requestArgs: map[string]interface{}{
+ "owner": "owner",
+ "tag": "v1.0.0",
+ },
+ expectError: false, // Returns tool error, not Go error
+ expectedErrMsg: "missing required parameter: repo",
+ },
+ {
+ name: "missing tag parameter",
+ mockedClient: mock.NewMockedHTTPClient(),
+ requestArgs: map[string]interface{}{
+ "owner": "owner",
+ "repo": "repo",
+ },
+ expectError: false, // Returns tool error, not Go error
+ expectedErrMsg: "missing required parameter: tag",
+ },
+ {
+ name: "release by tag not found",
+ mockedClient: mock.NewMockedHTTPClient(
+ mock.WithRequestMatchHandler(
+ mock.GetReposReleasesTagsByOwnerByRepoByTag,
+ http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(http.StatusNotFound)
+ _, _ = w.Write([]byte(`{"message": "Not Found"}`))
+ }),
+ ),
+ ),
+ requestArgs: map[string]interface{}{
+ "owner": "owner",
+ "repo": "repo",
+ "tag": "v999.0.0",
+ },
+ expectError: false, // API errors return tool errors, not Go errors
+ expectedErrMsg: "failed to get release by tag: v999.0.0",
+ },
+ {
+ name: "server error",
+ mockedClient: mock.NewMockedHTTPClient(
+ mock.WithRequestMatchHandler(
+ mock.GetReposReleasesTagsByOwnerByRepoByTag,
+ http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(http.StatusInternalServerError)
+ _, _ = w.Write([]byte(`{"message": "Internal Server Error"}`))
+ }),
+ ),
+ ),
+ requestArgs: map[string]interface{}{
+ "owner": "owner",
+ "repo": "repo",
+ "tag": "v1.0.0",
+ },
+ expectError: false, // API errors return tool errors, not Go errors
+ expectedErrMsg: "failed to get release by tag: v1.0.0",
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ client := github.NewClient(tc.mockedClient)
+ _, handler := GetReleaseByTag(stubGetClientFn(client), translations.NullTranslationHelper)
+
+ request := createMCPRequest(tc.requestArgs)
+
+ result, err := handler(context.Background(), request)
+
+ if tc.expectError {
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), tc.expectedErrMsg)
+ return
+ }
+
+ require.NoError(t, err)
+
+ if tc.expectedErrMsg != "" {
+ require.True(t, result.IsError)
+ errorContent := getErrorResult(t, result)
+ assert.Contains(t, errorContent.Text, tc.expectedErrMsg)
+ return
+ }
+
+ require.False(t, result.IsError)
+
+ textContent := getTextResult(t, result)
+
+ var returnedRelease github.RepositoryRelease
+ err = json.Unmarshal([]byte(textContent.Text), &returnedRelease)
+ require.NoError(t, err)
+
+ assert.Equal(t, *tc.expectedResult.ID, *returnedRelease.ID)
+ assert.Equal(t, *tc.expectedResult.TagName, *returnedRelease.TagName)
+ assert.Equal(t, *tc.expectedResult.Name, *returnedRelease.Name)
+ if tc.expectedResult.Body != nil {
+ assert.Equal(t, *tc.expectedResult.Body, *returnedRelease.Body)
+ }
+ if len(tc.expectedResult.Assets) > 0 {
+ require.Len(t, returnedRelease.Assets, len(tc.expectedResult.Assets))
+ assert.Equal(t, *tc.expectedResult.Assets[0].Name, *returnedRelease.Assets[0].Name)
+ }
+ })
+ }
+}
+
func Test_filterPaths(t *testing.T) {
tests := []struct {
name string
@@ -2212,63 +2623,239 @@ func Test_resolveGitReference(t *testing.T) {
ctx := context.Background()
owner := "owner"
repo := "repo"
- mockedClient := mock.NewMockedHTTPClient(
- mock.WithRequestMatchHandler(
- mock.GetReposByOwnerByRepo,
- http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
- w.WriteHeader(http.StatusOK)
- _, _ = w.Write([]byte(`{"name": "repo", "default_branch": "main"}`))
- }),
- ),
- mock.WithRequestMatchHandler(
- mock.GetReposGitRefByOwnerByRepoByRef,
- http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
- w.WriteHeader(http.StatusOK)
- _, _ = w.Write([]byte(`{"ref": "refs/heads/main", "object": {"sha": "123sha456"}}`))
- }),
- ),
- )
tests := []struct {
name string
ref string
sha string
+ mockSetup func() *http.Client
expectedOutput *raw.ContentOpts
+ expectError bool
+ errorContains string
}{
{
name: "sha takes precedence over ref",
ref: "refs/heads/main",
sha: "123sha456",
+ mockSetup: func() *http.Client {
+ // No API calls should be made when SHA is provided
+ return mock.NewMockedHTTPClient()
+ },
expectedOutput: &raw.ContentOpts{
SHA: "123sha456",
},
+ expectError: false,
},
{
name: "use default branch if ref and sha both empty",
ref: "",
sha: "",
+ mockSetup: func() *http.Client {
+ return mock.NewMockedHTTPClient(
+ mock.WithRequestMatchHandler(
+ mock.GetReposByOwnerByRepo,
+ http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write([]byte(`{"name": "repo", "default_branch": "main"}`))
+ }),
+ ),
+ mock.WithRequestMatchHandler(
+ mock.GetReposGitRefByOwnerByRepoByRef,
+ http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ assert.Contains(t, r.URL.Path, "/git/ref/heads/main")
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write([]byte(`{"ref": "refs/heads/main", "object": {"sha": "main-sha"}}`))
+ }),
+ ),
+ )
+ },
expectedOutput: &raw.ContentOpts{
Ref: "refs/heads/main",
- SHA: "123sha456",
+ SHA: "main-sha",
},
+ expectError: false,
},
{
- name: "get SHA from ref",
- ref: "refs/heads/main",
+ name: "fully qualified ref passed through unchanged",
+ ref: "refs/heads/feature-branch",
+ sha: "",
+ mockSetup: func() *http.Client {
+ return mock.NewMockedHTTPClient(
+ mock.WithRequestMatchHandler(
+ mock.GetReposGitRefByOwnerByRepoByRef,
+ http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ assert.Contains(t, r.URL.Path, "/git/ref/heads/feature-branch")
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write([]byte(`{"ref": "refs/heads/feature-branch", "object": {"sha": "feature-sha"}}`))
+ }),
+ ),
+ )
+ },
+ expectedOutput: &raw.ContentOpts{
+ Ref: "refs/heads/feature-branch",
+ SHA: "feature-sha",
+ },
+ expectError: false,
+ },
+ {
+ name: "short branch name resolves to refs/heads/",
+ ref: "main",
sha: "",
+ mockSetup: func() *http.Client {
+ return mock.NewMockedHTTPClient(
+ mock.WithRequestMatchHandler(
+ mock.GetReposGitRefByOwnerByRepoByRef,
+ http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if strings.Contains(r.URL.Path, "/git/ref/heads/main") {
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write([]byte(`{"ref": "refs/heads/main", "object": {"sha": "main-sha"}}`))
+ } else {
+ t.Errorf("Unexpected path: %s", r.URL.Path)
+ w.WriteHeader(http.StatusNotFound)
+ }
+ }),
+ ),
+ )
+ },
expectedOutput: &raw.ContentOpts{
Ref: "refs/heads/main",
- SHA: "123sha456",
+ SHA: "main-sha",
+ },
+ expectError: false,
+ },
+ {
+ name: "short tag name falls back to refs/tags/ when branch not found",
+ ref: "v1.0.0",
+ sha: "",
+ mockSetup: func() *http.Client {
+ return mock.NewMockedHTTPClient(
+ mock.WithRequestMatchHandler(
+ mock.GetReposGitRefByOwnerByRepoByRef,
+ http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ switch {
+ case strings.Contains(r.URL.Path, "/git/ref/heads/v1.0.0"):
+ w.WriteHeader(http.StatusNotFound)
+ _, _ = w.Write([]byte(`{"message": "Not Found"}`))
+ case strings.Contains(r.URL.Path, "/git/ref/tags/v1.0.0"):
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write([]byte(`{"ref": "refs/tags/v1.0.0", "object": {"sha": "tag-sha"}}`))
+ default:
+ t.Errorf("Unexpected path: %s", r.URL.Path)
+ w.WriteHeader(http.StatusNotFound)
+ }
+ }),
+ ),
+ )
+ },
+ expectedOutput: &raw.ContentOpts{
+ Ref: "refs/tags/v1.0.0",
+ SHA: "tag-sha",
+ },
+ expectError: false,
+ },
+ {
+ name: "heads/ prefix gets refs/ prepended",
+ ref: "heads/feature-branch",
+ sha: "",
+ mockSetup: func() *http.Client {
+ return mock.NewMockedHTTPClient(
+ mock.WithRequestMatchHandler(
+ mock.GetReposGitRefByOwnerByRepoByRef,
+ http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ assert.Contains(t, r.URL.Path, "/git/ref/heads/feature-branch")
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write([]byte(`{"ref": "refs/heads/feature-branch", "object": {"sha": "feature-sha"}}`))
+ }),
+ ),
+ )
+ },
+ expectedOutput: &raw.ContentOpts{
+ Ref: "refs/heads/feature-branch",
+ SHA: "feature-sha",
+ },
+ expectError: false,
+ },
+ {
+ name: "tags/ prefix gets refs/ prepended",
+ ref: "tags/v1.0.0",
+ sha: "",
+ mockSetup: func() *http.Client {
+ return mock.NewMockedHTTPClient(
+ mock.WithRequestMatchHandler(
+ mock.GetReposGitRefByOwnerByRepoByRef,
+ http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ assert.Contains(t, r.URL.Path, "/git/ref/tags/v1.0.0")
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write([]byte(`{"ref": "refs/tags/v1.0.0", "object": {"sha": "tag-sha"}}`))
+ }),
+ ),
+ )
+ },
+ expectedOutput: &raw.ContentOpts{
+ Ref: "refs/tags/v1.0.0",
+ SHA: "tag-sha",
+ },
+ expectError: false,
+ },
+ {
+ name: "invalid short name that doesn't exist as branch or tag",
+ ref: "nonexistent",
+ sha: "",
+ mockSetup: func() *http.Client {
+ return mock.NewMockedHTTPClient(
+ mock.WithRequestMatchHandler(
+ mock.GetReposGitRefByOwnerByRepoByRef,
+ http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ // Both branch and tag attempts should return 404
+ w.WriteHeader(http.StatusNotFound)
+ _, _ = w.Write([]byte(`{"message": "Not Found"}`))
+ }),
+ ),
+ )
+ },
+ expectError: true,
+ errorContains: "could not resolve ref \"nonexistent\" as a branch or a tag",
+ },
+ {
+ name: "fully qualified pull request ref",
+ ref: "refs/pull/123/head",
+ sha: "",
+ mockSetup: func() *http.Client {
+ return mock.NewMockedHTTPClient(
+ mock.WithRequestMatchHandler(
+ mock.GetReposGitRefByOwnerByRepoByRef,
+ http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ assert.Contains(t, r.URL.Path, "/git/ref/pull/123/head")
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write([]byte(`{"ref": "refs/pull/123/head", "object": {"sha": "pr-sha"}}`))
+ }),
+ ),
+ )
+ },
+ expectedOutput: &raw.ContentOpts{
+ Ref: "refs/pull/123/head",
+ SHA: "pr-sha",
},
+ expectError: false,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
// Setup client with mock
- client := github.NewClient(mockedClient)
+ client := github.NewClient(tc.mockSetup())
opts, err := resolveGitReference(ctx, client, owner, repo, tc.ref, tc.sha)
+
+ if tc.expectError {
+ require.Error(t, err)
+ if tc.errorContains != "" {
+ assert.Contains(t, err.Error(), tc.errorContains)
+ }
+ return
+ }
+
require.NoError(t, err)
+ require.NotNil(t, opts)
if tc.expectedOutput.SHA != "" {
assert.Equal(t, tc.expectedOutput.SHA, opts.SHA)
diff --git a/pkg/github/repository_resource.go b/pkg/github/repository_resource.go
index 70ca6ba65..b76c0b1e8 100644
--- a/pkg/github/repository_resource.go
+++ b/pkg/github/repository_resource.go
@@ -14,7 +14,7 @@ import (
"github.com/github/github-mcp-server/pkg/raw"
"github.com/github/github-mcp-server/pkg/translations"
- "github.com/google/go-github/v73/github"
+ "github.com/google/go-github/v74/github"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
)
diff --git a/pkg/github/repository_resource_test.go b/pkg/github/repository_resource_test.go
index 2e3e911a9..1c048c000 100644
--- a/pkg/github/repository_resource_test.go
+++ b/pkg/github/repository_resource_test.go
@@ -8,7 +8,7 @@ import (
"github.com/github/github-mcp-server/pkg/raw"
"github.com/github/github-mcp-server/pkg/translations"
- "github.com/google/go-github/v73/github"
+ "github.com/google/go-github/v74/github"
"github.com/mark3labs/mcp-go/mcp"
"github.com/migueleliasweb/go-github-mock/src/mock"
"github.com/stretchr/testify/require"
diff --git a/pkg/github/search.go b/pkg/github/search.go
index 04a1facc0..55e4cf8b4 100644
--- a/pkg/github/search.go
+++ b/pkg/github/search.go
@@ -8,7 +8,7 @@ import (
ghErrors "github.com/github/github-mcp-server/pkg/errors"
"github.com/github/github-mcp-server/pkg/translations"
- "github.com/google/go-github/v73/github"
+ "github.com/google/go-github/v74/github"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
)
@@ -16,14 +16,19 @@ import (
// SearchRepositories creates a tool to search for GitHub repositories.
func SearchRepositories(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
return mcp.NewTool("search_repositories",
- mcp.WithDescription(t("TOOL_SEARCH_REPOSITORIES_DESCRIPTION", "Search for GitHub repositories")),
+ mcp.WithDescription(t("TOOL_SEARCH_REPOSITORIES_DESCRIPTION", "Find GitHub repositories by name, description, readme, topics, or other metadata. Perfect for discovering projects, finding examples, or locating specific repositories across GitHub.")),
+
mcp.WithToolAnnotation(mcp.ToolAnnotation{
Title: t("TOOL_SEARCH_REPOSITORIES_USER_TITLE", "Search repositories"),
ReadOnlyHint: ToBoolPtr(true),
}),
mcp.WithString("query",
mcp.Required(),
- mcp.Description("Search query"),
+ mcp.Description("Repository search query. Examples: 'machine learning in:name stars:>1000 language:python', 'topic:react', 'user:facebook'. Supports advanced search syntax for precise filtering."),
+ ),
+ mcp.WithBoolean("minimal_output",
+ mcp.Description("Return minimal repository information (default: true). When false, returns full GitHub API repository objects."),
+ mcp.DefaultBool(true),
),
WithPagination(),
),
@@ -36,11 +41,14 @@ func SearchRepositories(getClient GetClientFn, t translations.TranslationHelperF
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
-
+ minimalOutput, err := OptionalBoolParamWithDefault(request, "minimal_output", true)
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
opts := &github.SearchOptions{
ListOptions: github.ListOptions{
- Page: pagination.page,
- PerPage: pagination.perPage,
+ Page: pagination.Page,
+ PerPage: pagination.PerPage,
},
}
@@ -66,9 +74,55 @@ func SearchRepositories(getClient GetClientFn, t translations.TranslationHelperF
return mcp.NewToolResultError(fmt.Sprintf("failed to search repositories: %s", string(body))), nil
}
- r, err := json.Marshal(result)
- if err != nil {
- return nil, fmt.Errorf("failed to marshal response: %w", err)
+ // 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 {
+ minimalRepo := MinimalRepository{
+ ID: repo.GetID(),
+ Name: repo.GetName(),
+ FullName: repo.GetFullName(),
+ Description: repo.GetDescription(),
+ HTMLURL: repo.GetHTMLURL(),
+ Language: repo.GetLanguage(),
+ Stars: repo.GetStargazersCount(),
+ Forks: repo.GetForksCount(),
+ OpenIssues: repo.GetOpenIssuesCount(),
+ Private: repo.GetPrivate(),
+ Fork: repo.GetFork(),
+ Archived: repo.GetArchived(),
+ DefaultBranch: repo.GetDefaultBranch(),
+ }
+
+ if repo.UpdatedAt != nil {
+ minimalRepo.UpdatedAt = repo.UpdatedAt.Format("2006-01-02T15:04:05Z")
+ }
+ if repo.CreatedAt != nil {
+ minimalRepo.CreatedAt = repo.CreatedAt.Format("2006-01-02T15:04:05Z")
+ }
+ if repo.Topics != nil {
+ minimalRepo.Topics = repo.Topics
+ }
+
+ minimalRepos = append(minimalRepos, minimalRepo)
+ }
+
+ minimalResult := &MinimalSearchRepositoriesResult{
+ TotalCount: result.GetTotal(),
+ IncompleteResults: result.GetIncompleteResults(),
+ Items: minimalRepos,
+ }
+
+ 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
@@ -78,26 +132,26 @@ func SearchRepositories(getClient GetClientFn, t translations.TranslationHelperF
// SearchCode creates a tool to search for code across GitHub repositories.
func SearchCode(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
return mcp.NewTool("search_code",
- mcp.WithDescription(t("TOOL_SEARCH_CODE_DESCRIPTION", "Search for code across GitHub repositories")),
+ mcp.WithDescription(t("TOOL_SEARCH_CODE_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.")),
mcp.WithToolAnnotation(mcp.ToolAnnotation{
Title: t("TOOL_SEARCH_CODE_USER_TITLE", "Search code"),
ReadOnlyHint: ToBoolPtr(true),
}),
- mcp.WithString("q",
+ mcp.WithString("query",
mcp.Required(),
- mcp.Description("Search query using GitHub code search syntax"),
+ mcp.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."),
),
mcp.WithString("sort",
mcp.Description("Sort field ('indexed' only)"),
),
mcp.WithString("order",
- mcp.Description("Sort order"),
+ mcp.Description("Sort order for results"),
mcp.Enum("asc", "desc"),
),
WithPagination(),
),
func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
- query, err := RequiredParam[string](request, "q")
+ query, err := RequiredParam[string](request, "query")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
@@ -118,8 +172,8 @@ func SearchCode(getClient GetClientFn, t translations.TranslationHelperFunc) (to
Sort: sort,
Order: order,
ListOptions: github.ListOptions{
- PerPage: pagination.perPage,
- Page: pagination.page,
+ PerPage: pagination.PerPage,
+ Page: pagination.Page,
},
}
@@ -155,21 +209,6 @@ func SearchCode(getClient GetClientFn, t translations.TranslationHelperFunc) (to
}
}
-// MinimalUser is the output type for user and organization search results.
-type MinimalUser struct {
- Login string `json:"login"`
- ID int64 `json:"id,omitempty"`
- ProfileURL string `json:"profile_url,omitempty"`
- AvatarURL string `json:"avatar_url,omitempty"`
- Details *UserDetails `json:"details,omitempty"` // Optional field for additional user details
-}
-
-type MinimalSearchUsersResult struct {
- TotalCount int `json:"total_count"`
- IncompleteResults bool `json:"incomplete_results"`
- Items []MinimalUser `json:"items"`
-}
-
func userOrOrgHandler(accountType string, getClient GetClientFn) server.ToolHandlerFunc {
return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
query, err := RequiredParam[string](request, "query")
@@ -193,8 +232,8 @@ func userOrOrgHandler(accountType string, getClient GetClientFn) server.ToolHand
Sort: sort,
Order: order,
ListOptions: github.ListOptions{
- PerPage: pagination.perPage,
- Page: pagination.page,
+ PerPage: pagination.PerPage,
+ Page: pagination.Page,
},
}
@@ -203,7 +242,10 @@ func userOrOrgHandler(accountType string, getClient GetClientFn) server.ToolHand
return nil, fmt.Errorf("failed to get GitHub client: %w", err)
}
- searchQuery := "type:" + accountType + " " + query
+ searchQuery := query
+ if !hasTypeFilter(query) {
+ searchQuery = "type:" + accountType + " " + query
+ }
result, resp, err := client.Search.Users(ctx, searchQuery, opts)
if err != nil {
return ghErrors.NewGitHubAPIErrorResponse(ctx,
@@ -258,17 +300,17 @@ func userOrOrgHandler(accountType string, getClient GetClientFn) server.ToolHand
// SearchUsers creates a tool to search for GitHub users.
func SearchUsers(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
return mcp.NewTool("search_users",
- mcp.WithDescription(t("TOOL_SEARCH_USERS_DESCRIPTION", "Search for GitHub users exclusively")),
+ mcp.WithDescription(t("TOOL_SEARCH_USERS_DESCRIPTION", "Find GitHub users by username, real name, or other profile information. Useful for locating developers, contributors, or team members.")),
mcp.WithToolAnnotation(mcp.ToolAnnotation{
Title: t("TOOL_SEARCH_USERS_USER_TITLE", "Search users"),
ReadOnlyHint: ToBoolPtr(true),
}),
mcp.WithString("query",
mcp.Required(),
- mcp.Description("Search query using GitHub users search syntax scoped to type:user"),
+ mcp.Description("User search query. Examples: 'john smith', 'location:seattle', 'followers:>100'. Search is automatically scoped to type:user."),
),
mcp.WithString("sort",
- mcp.Description("Sort field by category"),
+ mcp.Description("Sort users by number of followers or repositories, or when the person joined GitHub."),
mcp.Enum("followers", "repositories", "joined"),
),
mcp.WithString("order",
@@ -282,14 +324,15 @@ func SearchUsers(getClient GetClientFn, t translations.TranslationHelperFunc) (t
// SearchOrgs creates a tool to search for GitHub organizations.
func SearchOrgs(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
return mcp.NewTool("search_orgs",
- mcp.WithDescription(t("TOOL_SEARCH_ORGS_DESCRIPTION", "Search for GitHub organizations exclusively")),
+ mcp.WithDescription(t("TOOL_SEARCH_ORGS_DESCRIPTION", "Find GitHub organizations by name, location, or other organization metadata. Ideal for discovering companies, open source foundations, or teams.")),
+
mcp.WithToolAnnotation(mcp.ToolAnnotation{
Title: t("TOOL_SEARCH_ORGS_USER_TITLE", "Search organizations"),
ReadOnlyHint: ToBoolPtr(true),
}),
mcp.WithString("query",
mcp.Required(),
- mcp.Description("Search query using GitHub organizations search syntax scoped to type:org"),
+ mcp.Description("Organization search query. Examples: 'microsoft', 'location:california', 'created:>=2025-01-01'. Search is automatically scoped to type:org."),
),
mcp.WithString("sort",
mcp.Description("Sort field by category"),
diff --git a/pkg/github/search_test.go b/pkg/github/search_test.go
index 21f7a0ca2..91ca45af5 100644
--- a/pkg/github/search_test.go
+++ b/pkg/github/search_test.go
@@ -8,7 +8,7 @@ import (
"github.com/github/github-mcp-server/internal/toolsnaps"
"github.com/github/github-mcp-server/pkg/translations"
- "github.com/google/go-github/v73/github"
+ "github.com/google/go-github/v74/github"
"github.com/migueleliasweb/go-github-mock/src/mock"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -148,23 +148,80 @@ func Test_SearchRepositories(t *testing.T) {
textContent := getTextResult(t, result)
// Unmarshal and verify the result
- var returnedResult github.RepositoriesSearchResult
+ var returnedResult MinimalSearchRepositoriesResult
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.Repositories, len(tc.expectedResult.Repositories))
- for i, repo := range returnedResult.Repositories {
- 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)
+ 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)
}
})
}
}
+func Test_SearchRepositories_FullOutput(t *testing.T) {
+ mockSearchResult := &github.RepositoriesSearchResult{
+ Total: github.Ptr(1),
+ IncompleteResults: github.Ptr(false),
+ Repositories: []*github.Repository{
+ {
+ ID: github.Ptr(int64(12345)),
+ Name: github.Ptr("test-repo"),
+ FullName: github.Ptr("owner/test-repo"),
+ HTMLURL: github.Ptr("https://github.com/owner/test-repo"),
+ Description: github.Ptr("Test repository"),
+ StargazersCount: github.Ptr(100),
+ },
+ },
+ }
+
+ mockedClient := mock.NewMockedHTTPClient(
+ mock.WithRequestMatchHandler(
+ mock.GetSearchRepositories,
+ expectQueryParams(t, map[string]string{
+ "q": "golang test",
+ "page": "1",
+ "per_page": "30",
+ }).andThen(
+ mockResponse(t, http.StatusOK, mockSearchResult),
+ ),
+ ),
+ )
+
+ client := github.NewClient(mockedClient)
+ _, handlerTest := SearchRepositories(stubGetClientFn(client), translations.NullTranslationHelper)
+
+ request := createMCPRequest(map[string]interface{}{
+ "query": "golang test",
+ "minimal_output": false,
+ })
+
+ result, err := handlerTest(context.Background(), request)
+
+ require.NoError(t, err)
+ require.False(t, result.IsError)
+
+ textContent := getTextResult(t, result)
+
+ // Unmarshal as full GitHub API response
+ var returnedResult github.RepositoriesSearchResult
+ 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)
+}
+
func Test_SearchCode(t *testing.T) {
// Verify tool definition once
mockClient := github.NewClient(nil)
@@ -173,12 +230,12 @@ func Test_SearchCode(t *testing.T) {
assert.Equal(t, "search_code", tool.Name)
assert.NotEmpty(t, tool.Description)
- assert.Contains(t, tool.InputSchema.Properties, "q")
+ 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.ElementsMatch(t, tool.InputSchema.Required, []string{"q"})
+ assert.ElementsMatch(t, tool.InputSchema.Required, []string{"query"})
// Setup mock search results
mockSearchResult := &github.CodeSearchResult{
@@ -227,7 +284,7 @@ func Test_SearchCode(t *testing.T) {
),
),
requestArgs: map[string]interface{}{
- "q": "fmt.Println language:go",
+ "query": "fmt.Println language:go",
"sort": "indexed",
"order": "desc",
"page": float64(1),
@@ -251,7 +308,7 @@ func Test_SearchCode(t *testing.T) {
),
),
requestArgs: map[string]interface{}{
- "q": "fmt.Println language:go",
+ "query": "fmt.Println language:go",
},
expectError: false,
expectedResult: mockSearchResult,
@@ -268,7 +325,7 @@ func Test_SearchCode(t *testing.T) {
),
),
requestArgs: map[string]interface{}{
- "q": "invalid:query",
+ "query": "invalid:query",
},
expectError: true,
expectedErrMsg: "failed to search code",
@@ -410,6 +467,46 @@ func Test_SearchUsers(t *testing.T) {
expectError: false,
expectedResult: mockSearchResult,
},
+ {
+ name: "query with existing type:user filter - no duplication",
+ mockedClient: mock.NewMockedHTTPClient(
+ mock.WithRequestMatchHandler(
+ mock.GetSearchUsers,
+ expectQueryParams(t, map[string]string{
+ "q": "type:user location:seattle followers:>100",
+ "page": "1",
+ "per_page": "30",
+ }).andThen(
+ mockResponse(t, http.StatusOK, mockSearchResult),
+ ),
+ ),
+ ),
+ requestArgs: map[string]interface{}{
+ "query": "type:user location:seattle followers:>100",
+ },
+ expectError: false,
+ expectedResult: mockSearchResult,
+ },
+ {
+ name: "complex query with existing type:user filter and OR operators",
+ mockedClient: mock.NewMockedHTTPClient(
+ mock.WithRequestMatchHandler(
+ mock.GetSearchUsers,
+ expectQueryParams(t, map[string]string{
+ "q": "type:user (location:seattle OR location:california) followers:>50",
+ "page": "1",
+ "per_page": "30",
+ }).andThen(
+ mockResponse(t, http.StatusOK, mockSearchResult),
+ ),
+ ),
+ ),
+ requestArgs: map[string]interface{}{
+ "query": "type:user (location:seattle OR location:california) followers:>50",
+ },
+ expectError: false,
+ expectedResult: mockSearchResult,
+ },
{
name: "search users fails",
mockedClient: mock.NewMockedHTTPClient(
@@ -537,6 +634,46 @@ func Test_SearchOrgs(t *testing.T) {
expectError: false,
expectedResult: mockSearchResult,
},
+ {
+ name: "query with existing type:org filter - no duplication",
+ mockedClient: mock.NewMockedHTTPClient(
+ mock.WithRequestMatchHandler(
+ mock.GetSearchUsers,
+ expectQueryParams(t, map[string]string{
+ "q": "type:org location:california followers:>1000",
+ "page": "1",
+ "per_page": "30",
+ }).andThen(
+ mockResponse(t, http.StatusOK, mockSearchResult),
+ ),
+ ),
+ ),
+ requestArgs: map[string]interface{}{
+ "query": "type:org location:california followers:>1000",
+ },
+ expectError: false,
+ expectedResult: mockSearchResult,
+ },
+ {
+ name: "complex query with existing type:org filter and OR operators",
+ mockedClient: mock.NewMockedHTTPClient(
+ mock.WithRequestMatchHandler(
+ mock.GetSearchUsers,
+ expectQueryParams(t, map[string]string{
+ "q": "type:org (location:seattle OR location:california OR location:newyork) repos:>10",
+ "page": "1",
+ "per_page": "30",
+ }).andThen(
+ mockResponse(t, http.StatusOK, mockSearchResult),
+ ),
+ ),
+ ),
+ requestArgs: map[string]interface{}{
+ "query": "type:org (location:seattle OR location:california OR location:newyork) repos:>10",
+ },
+ expectError: false,
+ expectedResult: mockSearchResult,
+ },
{
name: "org search fails",
mockedClient: mock.NewMockedHTTPClient(
diff --git a/pkg/github/search_utils.go b/pkg/github/search_utils.go
index 5dd48040e..159518c91 100644
--- a/pkg/github/search_utils.go
+++ b/pkg/github/search_utils.go
@@ -6,11 +6,35 @@ import (
"fmt"
"io"
"net/http"
+ "regexp"
- "github.com/google/go-github/v73/github"
+ "github.com/google/go-github/v74/github"
"github.com/mark3labs/mcp-go/mcp"
)
+func hasFilter(query, filterType string) bool {
+ // Match filter at start of string, after whitespace, or after non-word characters like '('
+ pattern := fmt.Sprintf(`(^|\s|\W)%s:\S+`, regexp.QuoteMeta(filterType))
+ matched, _ := regexp.MatchString(pattern, query)
+ return matched
+}
+
+func hasSpecificFilter(query, filterType, filterValue string) bool {
+ // Match specific filter:value at start, after whitespace, or after non-word characters
+ // End with word boundary, whitespace, or non-word characters like ')'
+ pattern := fmt.Sprintf(`(^|\s|\W)%s:%s($|\s|\W)`, regexp.QuoteMeta(filterType), regexp.QuoteMeta(filterValue))
+ matched, _ := regexp.MatchString(pattern, query)
+ return matched
+}
+
+func hasRepoFilter(query string) bool {
+ return hasFilter(query, "repo")
+}
+
+func hasTypeFilter(query string) bool {
+ return hasFilter(query, "type")
+}
+
func searchHandler(
ctx context.Context,
getClient GetClientFn,
@@ -22,7 +46,10 @@ func searchHandler(
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
- query = fmt.Sprintf("is:%s %s", searchType, query)
+
+ if !hasSpecificFilter(query, "is", searchType) {
+ query = fmt.Sprintf("is:%s %s", searchType, query)
+ }
owner, err := OptionalParam[string](request, "owner")
if err != nil {
@@ -34,7 +61,7 @@ func searchHandler(
return mcp.NewToolResultError(err.Error()), nil
}
- if owner != "" && repo != "" {
+ if owner != "" && repo != "" && !hasRepoFilter(query) {
query = fmt.Sprintf("repo:%s/%s %s", owner, repo, query)
}
@@ -56,8 +83,8 @@ func searchHandler(
Sort: sort,
Order: order,
ListOptions: github.ListOptions{
- Page: pagination.page,
- PerPage: pagination.perPage,
+ Page: pagination.Page,
+ PerPage: pagination.PerPage,
},
}
diff --git a/pkg/github/search_utils_test.go b/pkg/github/search_utils_test.go
new file mode 100644
index 000000000..85f953eed
--- /dev/null
+++ b/pkg/github/search_utils_test.go
@@ -0,0 +1,352 @@
+package github
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func Test_hasFilter(t *testing.T) {
+ tests := []struct {
+ name string
+ query string
+ filterType string
+ expected bool
+ }{
+ {
+ name: "query has is:issue filter",
+ query: "is:issue bug report",
+ filterType: "is",
+ expected: true,
+ },
+ {
+ name: "query has repo: filter",
+ query: "repo:github/github-mcp-server critical bug",
+ filterType: "repo",
+ expected: true,
+ },
+ {
+ name: "query has multiple is: filters",
+ query: "is:issue is:open bug",
+ filterType: "is",
+ expected: true,
+ },
+ {
+ name: "query has filter at the beginning",
+ query: "is:issue some text",
+ filterType: "is",
+ expected: true,
+ },
+ {
+ name: "query has filter in the middle",
+ query: "some text is:issue more text",
+ filterType: "is",
+ expected: true,
+ },
+ {
+ name: "query has filter at the end",
+ query: "some text is:issue",
+ filterType: "is",
+ expected: true,
+ },
+ {
+ name: "query does not have the filter",
+ query: "bug report critical",
+ filterType: "is",
+ expected: false,
+ },
+ {
+ name: "query has similar text but not the filter",
+ query: "this issue is important",
+ filterType: "is",
+ expected: false,
+ },
+ {
+ name: "empty query",
+ query: "",
+ filterType: "is",
+ expected: false,
+ },
+ {
+ name: "query has label: filter but looking for is:",
+ query: "label:bug critical",
+ filterType: "is",
+ expected: false,
+ },
+ {
+ name: "query has author: filter",
+ query: "author:octocat bug",
+ filterType: "author",
+ expected: true,
+ },
+ {
+ name: "query with complex OR expression",
+ query: "repo:github/github-mcp-server is:issue (label:critical OR label:urgent)",
+ filterType: "is",
+ expected: true,
+ },
+ {
+ name: "query with complex OR expression checking repo",
+ query: "repo:github/github-mcp-server is:issue (label:critical OR label:urgent)",
+ filterType: "repo",
+ expected: true,
+ },
+ {
+ name: "filter in parentheses at start",
+ query: "(label:bug OR owner:bob) is:issue",
+ filterType: "label",
+ expected: true,
+ },
+ {
+ name: "filter after opening parenthesis",
+ query: "is:issue (label:critical OR repo:test/test)",
+ filterType: "label",
+ expected: true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ result := hasFilter(tt.query, tt.filterType)
+ assert.Equal(t, tt.expected, result, "hasFilter(%q, %q) = %v, expected %v", tt.query, tt.filterType, result, tt.expected)
+ })
+ }
+}
+
+func Test_hasRepoFilter(t *testing.T) {
+ tests := []struct {
+ name string
+ query string
+ expected bool
+ }{
+ {
+ name: "query with repo: filter at beginning",
+ query: "repo:github/github-mcp-server is:issue",
+ expected: true,
+ },
+ {
+ name: "query with repo: filter in middle",
+ query: "is:issue repo:octocat/Hello-World bug",
+ expected: true,
+ },
+ {
+ name: "query with repo: filter at end",
+ query: "is:issue critical repo:owner/repo-name",
+ expected: true,
+ },
+ {
+ name: "query with complex repo name",
+ query: "repo:microsoft/vscode-extension-samples bug",
+ expected: true,
+ },
+ {
+ name: "query without repo: filter",
+ query: "is:issue bug critical",
+ expected: false,
+ },
+ {
+ name: "query with malformed repo: filter (no slash)",
+ query: "repo:github bug",
+ expected: true, // hasRepoFilter only checks for repo: prefix, not format
+ },
+ {
+ name: "empty query",
+ query: "",
+ expected: false,
+ },
+ {
+ name: "query with multiple repo: filters",
+ query: "repo:github/first repo:octocat/second",
+ expected: true,
+ },
+ {
+ name: "query with repo: in text but not as filter",
+ query: "this repo: is important",
+ expected: false,
+ },
+ {
+ name: "query with complex OR expression",
+ query: "repo:github/github-mcp-server is:issue (label:critical OR label:urgent)",
+ expected: true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ result := hasRepoFilter(tt.query)
+ assert.Equal(t, tt.expected, result, "hasRepoFilter(%q) = %v, expected %v", tt.query, result, tt.expected)
+ })
+ }
+}
+
+func Test_hasSpecificFilter(t *testing.T) {
+ tests := []struct {
+ name string
+ query string
+ filterType string
+ filterValue string
+ expected bool
+ }{
+ {
+ name: "query has exact is:issue filter",
+ query: "is:issue bug report",
+ filterType: "is",
+ filterValue: "issue",
+ expected: true,
+ },
+ {
+ name: "query has is:open but looking for is:issue",
+ query: "is:open bug report",
+ filterType: "is",
+ filterValue: "issue",
+ expected: false,
+ },
+ {
+ name: "query has both is:issue and is:open, looking for is:issue",
+ query: "is:issue is:open bug",
+ filterType: "is",
+ filterValue: "issue",
+ expected: true,
+ },
+ {
+ name: "query has both is:issue and is:open, looking for is:open",
+ query: "is:issue is:open bug",
+ filterType: "is",
+ filterValue: "open",
+ expected: true,
+ },
+ {
+ name: "query has is:issue at the beginning",
+ query: "is:issue some text",
+ filterType: "is",
+ filterValue: "issue",
+ expected: true,
+ },
+ {
+ name: "query has is:issue in the middle",
+ query: "some text is:issue more text",
+ filterType: "is",
+ filterValue: "issue",
+ expected: true,
+ },
+ {
+ name: "query has is:issue at the end",
+ query: "some text is:issue",
+ filterType: "is",
+ filterValue: "issue",
+ expected: true,
+ },
+ {
+ name: "query does not have is:issue",
+ query: "bug report critical",
+ filterType: "is",
+ filterValue: "issue",
+ expected: false,
+ },
+ {
+ name: "query has similar text but not the exact filter",
+ query: "this issue is important",
+ filterType: "is",
+ filterValue: "issue",
+ expected: false,
+ },
+ {
+ name: "empty query",
+ query: "",
+ filterType: "is",
+ filterValue: "issue",
+ expected: false,
+ },
+ {
+ name: "partial match should not count",
+ query: "is:issues bug", // "issues" vs "issue"
+ filterType: "is",
+ filterValue: "issue",
+ expected: false,
+ },
+ {
+ name: "complex query with parentheses",
+ query: "repo:github/github-mcp-server is:issue (label:critical OR label:urgent)",
+ filterType: "is",
+ filterValue: "issue",
+ expected: true,
+ },
+ {
+ name: "filter:value in parentheses at start",
+ query: "(is:issue OR is:pr) label:bug",
+ filterType: "is",
+ filterValue: "issue",
+ expected: true,
+ },
+ {
+ name: "filter:value after opening parenthesis",
+ query: "repo:test/repo (is:issue AND label:bug)",
+ filterType: "is",
+ filterValue: "issue",
+ expected: true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ result := hasSpecificFilter(tt.query, tt.filterType, tt.filterValue)
+ assert.Equal(t, tt.expected, result, "hasSpecificFilter(%q, %q, %q) = %v, expected %v", tt.query, tt.filterType, tt.filterValue, result, tt.expected)
+ })
+ }
+}
+
+func Test_hasTypeFilter(t *testing.T) {
+ tests := []struct {
+ name string
+ query string
+ expected bool
+ }{
+ {
+ name: "query with type:user filter at beginning",
+ query: "type:user location:seattle",
+ expected: true,
+ },
+ {
+ name: "query with type:org filter in middle",
+ query: "location:california type:org followers:>100",
+ expected: true,
+ },
+ {
+ name: "query with type:user filter at end",
+ query: "location:seattle followers:>50 type:user",
+ expected: true,
+ },
+ {
+ name: "query without type: filter",
+ query: "location:seattle followers:>50",
+ expected: false,
+ },
+ {
+ name: "empty query",
+ query: "",
+ expected: false,
+ },
+ {
+ name: "query with type: in text but not as filter",
+ query: "this type: is important",
+ expected: false,
+ },
+ {
+ name: "query with multiple type: filters",
+ query: "type:user type:org",
+ expected: true,
+ },
+ {
+ name: "complex query with OR expression",
+ query: "type:user (location:seattle OR location:california)",
+ expected: true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ result := hasTypeFilter(tt.query)
+ assert.Equal(t, tt.expected, result, "hasTypeFilter(%q) = %v, expected %v", tt.query, result, tt.expected)
+ })
+ }
+}
diff --git a/pkg/github/secret_scanning.go b/pkg/github/secret_scanning.go
index dc199b4e6..c140c34ad 100644
--- a/pkg/github/secret_scanning.go
+++ b/pkg/github/secret_scanning.go
@@ -9,7 +9,7 @@ import (
ghErrors "github.com/github/github-mcp-server/pkg/errors"
"github.com/github/github-mcp-server/pkg/translations"
- "github.com/google/go-github/v73/github"
+ "github.com/google/go-github/v74/github"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
)
diff --git a/pkg/github/secret_scanning_test.go b/pkg/github/secret_scanning_test.go
index 96b281830..ce33fe318 100644
--- a/pkg/github/secret_scanning_test.go
+++ b/pkg/github/secret_scanning_test.go
@@ -7,7 +7,7 @@ import (
"testing"
"github.com/github/github-mcp-server/pkg/translations"
- "github.com/google/go-github/v73/github"
+ "github.com/google/go-github/v74/github"
"github.com/migueleliasweb/go-github-mock/src/mock"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
diff --git a/pkg/github/security_advisories.go b/pkg/github/security_advisories.go
new file mode 100644
index 000000000..6eaeebe47
--- /dev/null
+++ b/pkg/github/security_advisories.go
@@ -0,0 +1,397 @@
+package github
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+
+ "github.com/github/github-mcp-server/pkg/translations"
+ "github.com/google/go-github/v74/github"
+ "github.com/mark3labs/mcp-go/mcp"
+ "github.com/mark3labs/mcp-go/server"
+)
+
+func ListGlobalSecurityAdvisories(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
+ return mcp.NewTool("list_global_security_advisories",
+ mcp.WithDescription(t("TOOL_LIST_GLOBAL_SECURITY_ADVISORIES_DESCRIPTION", "List global security advisories from GitHub.")),
+ mcp.WithToolAnnotation(mcp.ToolAnnotation{
+ Title: t("TOOL_LIST_GLOBAL_SECURITY_ADVISORIES_USER_TITLE", "List global security advisories"),
+ ReadOnlyHint: ToBoolPtr(true),
+ }),
+ mcp.WithString("ghsaId",
+ mcp.Description("Filter by GitHub Security Advisory ID (format: GHSA-xxxx-xxxx-xxxx)."),
+ ),
+ mcp.WithString("type",
+ mcp.Description("Advisory type."),
+ mcp.Enum("reviewed", "malware", "unreviewed"),
+ mcp.DefaultString("reviewed"),
+ ),
+ mcp.WithString("cveId",
+ mcp.Description("Filter by CVE ID."),
+ ),
+ mcp.WithString("ecosystem",
+ mcp.Description("Filter by package ecosystem."),
+ mcp.Enum("actions", "composer", "erlang", "go", "maven", "npm", "nuget", "other", "pip", "pub", "rubygems", "rust"),
+ ),
+ mcp.WithString("severity",
+ mcp.Description("Filter by severity."),
+ mcp.Enum("unknown", "low", "medium", "high", "critical"),
+ ),
+ mcp.WithArray("cwes",
+ mcp.Description("Filter by Common Weakness Enumeration IDs (e.g. [\"79\", \"284\", \"22\"])."),
+ mcp.Items(map[string]any{
+ "type": "string",
+ }),
+ ),
+ mcp.WithBoolean("isWithdrawn",
+ mcp.Description("Whether to only return withdrawn advisories."),
+ ),
+ mcp.WithString("affects",
+ mcp.Description("Filter advisories by affected package or version (e.g. \"package1,package2@1.0.0\")."),
+ ),
+ mcp.WithString("published",
+ mcp.Description("Filter by publish date or date range (ISO 8601 date or range)."),
+ ),
+ mcp.WithString("updated",
+ mcp.Description("Filter by update date or date range (ISO 8601 date or range)."),
+ ),
+ mcp.WithString("modified",
+ mcp.Description("Filter by publish or update date or date range (ISO 8601 date or range)."),
+ ),
+ ), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
+ client, err := getClient(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get GitHub client: %w", err)
+ }
+
+ ghsaID, err := OptionalParam[string](request, "ghsaId")
+ if err != nil {
+ return mcp.NewToolResultError(fmt.Sprintf("invalid ghsaId: %v", err)), nil
+ }
+
+ typ, err := OptionalParam[string](request, "type")
+ if err != nil {
+ return mcp.NewToolResultError(fmt.Sprintf("invalid type: %v", err)), nil
+ }
+
+ cveID, err := OptionalParam[string](request, "cveId")
+ if err != nil {
+ return mcp.NewToolResultError(fmt.Sprintf("invalid cveId: %v", err)), nil
+ }
+
+ eco, err := OptionalParam[string](request, "ecosystem")
+ if err != nil {
+ return mcp.NewToolResultError(fmt.Sprintf("invalid ecosystem: %v", err)), nil
+ }
+
+ sev, err := OptionalParam[string](request, "severity")
+ if err != nil {
+ return mcp.NewToolResultError(fmt.Sprintf("invalid severity: %v", err)), nil
+ }
+
+ cwes, err := OptionalParam[[]string](request, "cwes")
+ if err != nil {
+ return mcp.NewToolResultError(fmt.Sprintf("invalid cwes: %v", err)), nil
+ }
+
+ isWithdrawn, err := OptionalParam[bool](request, "isWithdrawn")
+ if err != nil {
+ return mcp.NewToolResultError(fmt.Sprintf("invalid isWithdrawn: %v", err)), nil
+ }
+
+ affects, err := OptionalParam[string](request, "affects")
+ if err != nil {
+ return mcp.NewToolResultError(fmt.Sprintf("invalid affects: %v", err)), nil
+ }
+
+ published, err := OptionalParam[string](request, "published")
+ if err != nil {
+ return mcp.NewToolResultError(fmt.Sprintf("invalid published: %v", err)), nil
+ }
+
+ updated, err := OptionalParam[string](request, "updated")
+ if err != nil {
+ return mcp.NewToolResultError(fmt.Sprintf("invalid updated: %v", err)), nil
+ }
+
+ modified, err := OptionalParam[string](request, "modified")
+ if err != nil {
+ return mcp.NewToolResultError(fmt.Sprintf("invalid modified: %v", err)), nil
+ }
+
+ opts := &github.ListGlobalSecurityAdvisoriesOptions{}
+
+ if ghsaID != "" {
+ opts.GHSAID = &ghsaID
+ }
+ if typ != "" {
+ opts.Type = &typ
+ }
+ if cveID != "" {
+ opts.CVEID = &cveID
+ }
+ if eco != "" {
+ opts.Ecosystem = &eco
+ }
+ if sev != "" {
+ opts.Severity = &sev
+ }
+ if len(cwes) > 0 {
+ opts.CWEs = cwes
+ }
+
+ if isWithdrawn {
+ opts.IsWithdrawn = &isWithdrawn
+ }
+
+ if affects != "" {
+ opts.Affects = &affects
+ }
+ if published != "" {
+ opts.Published = &published
+ }
+ if updated != "" {
+ opts.Updated = &updated
+ }
+ if modified != "" {
+ opts.Modified = &modified
+ }
+
+ advisories, resp, err := client.SecurityAdvisories.ListGlobalSecurityAdvisories(ctx, opts)
+ if err != nil {
+ return nil, fmt.Errorf("failed to list global security advisories: %w", err)
+ }
+ defer func() { _ = resp.Body.Close() }()
+
+ if resp.StatusCode != http.StatusOK {
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, fmt.Errorf("failed to read response body: %w", err)
+ }
+ return mcp.NewToolResultError(fmt.Sprintf("failed to list advisories: %s", string(body))), nil
+ }
+
+ r, err := json.Marshal(advisories)
+ if err != nil {
+ return nil, fmt.Errorf("failed to marshal advisories: %w", err)
+ }
+
+ return mcp.NewToolResultText(string(r)), nil
+ }
+}
+
+func ListRepositorySecurityAdvisories(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
+ return mcp.NewTool("list_repository_security_advisories",
+ mcp.WithDescription(t("TOOL_LIST_REPOSITORY_SECURITY_ADVISORIES_DESCRIPTION", "List repository security advisories for a GitHub repository.")),
+ mcp.WithToolAnnotation(mcp.ToolAnnotation{
+ Title: t("TOOL_LIST_REPOSITORY_SECURITY_ADVISORIES_USER_TITLE", "List repository security advisories"),
+ ReadOnlyHint: ToBoolPtr(true),
+ }),
+ mcp.WithString("owner",
+ mcp.Required(),
+ mcp.Description("The owner of the repository."),
+ ),
+ mcp.WithString("repo",
+ mcp.Required(),
+ mcp.Description("The name of the repository."),
+ ),
+ mcp.WithString("direction",
+ mcp.Description("Sort direction."),
+ mcp.Enum("asc", "desc"),
+ ),
+ mcp.WithString("sort",
+ mcp.Description("Sort field."),
+ mcp.Enum("created", "updated", "published"),
+ ),
+ mcp.WithString("state",
+ mcp.Description("Filter by advisory state."),
+ mcp.Enum("triage", "draft", "published", "closed"),
+ ),
+ ), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
+ owner, err := RequiredParam[string](request, "owner")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+ repo, err := RequiredParam[string](request, "repo")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+
+ direction, err := OptionalParam[string](request, "direction")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+ sortField, err := OptionalParam[string](request, "sort")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+ state, err := OptionalParam[string](request, "state")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+
+ client, err := getClient(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get GitHub client: %w", err)
+ }
+
+ opts := &github.ListRepositorySecurityAdvisoriesOptions{}
+ if direction != "" {
+ opts.Direction = direction
+ }
+ if sortField != "" {
+ opts.Sort = sortField
+ }
+ if state != "" {
+ opts.State = state
+ }
+
+ advisories, resp, err := client.SecurityAdvisories.ListRepositorySecurityAdvisories(ctx, owner, repo, opts)
+ if err != nil {
+ return nil, fmt.Errorf("failed to list repository security advisories: %w", err)
+ }
+ defer func() { _ = resp.Body.Close() }()
+
+ if resp.StatusCode != http.StatusOK {
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, fmt.Errorf("failed to read response body: %w", err)
+ }
+ return mcp.NewToolResultError(fmt.Sprintf("failed to list repository advisories: %s", string(body))), nil
+ }
+
+ r, err := json.Marshal(advisories)
+ if err != nil {
+ return nil, fmt.Errorf("failed to marshal advisories: %w", err)
+ }
+
+ return mcp.NewToolResultText(string(r)), nil
+ }
+}
+
+func GetGlobalSecurityAdvisory(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
+ return mcp.NewTool("get_global_security_advisory",
+ mcp.WithDescription(t("TOOL_GET_GLOBAL_SECURITY_ADVISORY_DESCRIPTION", "Get a global security advisory")),
+ mcp.WithToolAnnotation(mcp.ToolAnnotation{
+ Title: t("TOOL_GET_GLOBAL_SECURITY_ADVISORY_USER_TITLE", "Get a global security advisory"),
+ ReadOnlyHint: ToBoolPtr(true),
+ }),
+ mcp.WithString("ghsaId",
+ mcp.Description("GitHub Security Advisory ID (format: GHSA-xxxx-xxxx-xxxx)."),
+ mcp.Required(),
+ ),
+ ), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
+ client, err := getClient(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get GitHub client: %w", err)
+ }
+
+ ghsaID, err := RequiredParam[string](request, "ghsaId")
+ if err != nil {
+ return mcp.NewToolResultError(fmt.Sprintf("invalid ghsaId: %v", err)), nil
+ }
+
+ advisory, resp, err := client.SecurityAdvisories.GetGlobalSecurityAdvisories(ctx, ghsaID)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get advisory: %w", err)
+ }
+ defer func() { _ = resp.Body.Close() }()
+
+ if resp.StatusCode != http.StatusOK {
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, fmt.Errorf("failed to read response body: %w", err)
+ }
+ return mcp.NewToolResultError(fmt.Sprintf("failed to get advisory: %s", string(body))), nil
+ }
+
+ r, err := json.Marshal(advisory)
+ if err != nil {
+ return nil, fmt.Errorf("failed to marshal advisory: %w", err)
+ }
+
+ return mcp.NewToolResultText(string(r)), nil
+ }
+}
+
+func ListOrgRepositorySecurityAdvisories(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
+ return mcp.NewTool("list_org_repository_security_advisories",
+ mcp.WithDescription(t("TOOL_LIST_ORG_REPOSITORY_SECURITY_ADVISORIES_DESCRIPTION", "List repository security advisories for a GitHub organization.")),
+ mcp.WithToolAnnotation(mcp.ToolAnnotation{
+ Title: t("TOOL_LIST_ORG_REPOSITORY_SECURITY_ADVISORIES_USER_TITLE", "List org repository security advisories"),
+ ReadOnlyHint: ToBoolPtr(true),
+ }),
+ mcp.WithString("org",
+ mcp.Required(),
+ mcp.Description("The organization login."),
+ ),
+ mcp.WithString("direction",
+ mcp.Description("Sort direction."),
+ mcp.Enum("asc", "desc"),
+ ),
+ mcp.WithString("sort",
+ mcp.Description("Sort field."),
+ mcp.Enum("created", "updated", "published"),
+ ),
+ mcp.WithString("state",
+ mcp.Description("Filter by advisory state."),
+ mcp.Enum("triage", "draft", "published", "closed"),
+ ),
+ ), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
+ org, err := RequiredParam[string](request, "org")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+ direction, err := OptionalParam[string](request, "direction")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+ sortField, err := OptionalParam[string](request, "sort")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+ state, err := OptionalParam[string](request, "state")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+
+ client, err := getClient(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get GitHub client: %w", err)
+ }
+
+ opts := &github.ListRepositorySecurityAdvisoriesOptions{}
+ if direction != "" {
+ opts.Direction = direction
+ }
+ if sortField != "" {
+ opts.Sort = sortField
+ }
+ if state != "" {
+ opts.State = state
+ }
+
+ advisories, resp, err := client.SecurityAdvisories.ListRepositorySecurityAdvisoriesForOrg(ctx, org, opts)
+ if err != nil {
+ return nil, fmt.Errorf("failed to list organization repository security advisories: %w", err)
+ }
+ defer func() { _ = resp.Body.Close() }()
+
+ if resp.StatusCode != http.StatusOK {
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, fmt.Errorf("failed to read response body: %w", err)
+ }
+ return mcp.NewToolResultError(fmt.Sprintf("failed to list organization repository advisories: %s", string(body))), nil
+ }
+
+ r, err := json.Marshal(advisories)
+ if err != nil {
+ return nil, fmt.Errorf("failed to marshal advisories: %w", err)
+ }
+
+ return mcp.NewToolResultText(string(r)), nil
+ }
+}
diff --git a/pkg/github/security_advisories_test.go b/pkg/github/security_advisories_test.go
new file mode 100644
index 000000000..0640f917d
--- /dev/null
+++ b/pkg/github/security_advisories_test.go
@@ -0,0 +1,526 @@
+package github
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "testing"
+
+ "github.com/github/github-mcp-server/pkg/translations"
+ "github.com/google/go-github/v74/github"
+ "github.com/migueleliasweb/go-github-mock/src/mock"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func Test_ListGlobalSecurityAdvisories(t *testing.T) {
+ mockClient := github.NewClient(nil)
+ tool, _ := ListGlobalSecurityAdvisories(stubGetClientFn(mockClient), translations.NullTranslationHelper)
+
+ assert.Equal(t, "list_global_security_advisories", tool.Name)
+ assert.NotEmpty(t, tool.Description)
+ assert.Contains(t, tool.InputSchema.Properties, "ecosystem")
+ assert.Contains(t, tool.InputSchema.Properties, "severity")
+ assert.Contains(t, tool.InputSchema.Properties, "ghsaId")
+ assert.ElementsMatch(t, tool.InputSchema.Required, []string{})
+
+ // Setup mock advisory for success case
+ mockAdvisory := &github.GlobalSecurityAdvisory{
+ SecurityAdvisory: github.SecurityAdvisory{
+ GHSAID: github.Ptr("GHSA-xxxx-xxxx-xxxx"),
+ Summary: github.Ptr("Test advisory"),
+ Description: github.Ptr("This is a test advisory."),
+ Severity: github.Ptr("high"),
+ },
+ }
+
+ tests := []struct {
+ name string
+ mockedClient *http.Client
+ requestArgs map[string]interface{}
+ expectError bool
+ expectedAdvisories []*github.GlobalSecurityAdvisory
+ expectedErrMsg string
+ }{
+ {
+ name: "successful advisory fetch",
+ mockedClient: mock.NewMockedHTTPClient(
+ mock.WithRequestMatch(
+ mock.GetAdvisories,
+ []*github.GlobalSecurityAdvisory{mockAdvisory},
+ ),
+ ),
+ requestArgs: map[string]interface{}{
+ "type": "reviewed",
+ "ecosystem": "npm",
+ "severity": "high",
+ },
+ expectError: false,
+ expectedAdvisories: []*github.GlobalSecurityAdvisory{mockAdvisory},
+ },
+ {
+ name: "invalid severity value",
+ mockedClient: mock.NewMockedHTTPClient(
+ mock.WithRequestMatchHandler(
+ mock.GetAdvisories,
+ http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(http.StatusBadRequest)
+ _, _ = w.Write([]byte(`{"message": "Bad Request"}`))
+ }),
+ ),
+ ),
+ requestArgs: map[string]interface{}{
+ "type": "reviewed",
+ "severity": "extreme",
+ },
+ expectError: true,
+ expectedErrMsg: "failed to list global security advisories",
+ },
+ {
+ name: "API error handling",
+ mockedClient: mock.NewMockedHTTPClient(
+ mock.WithRequestMatchHandler(
+ mock.GetAdvisories,
+ http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(http.StatusInternalServerError)
+ _, _ = w.Write([]byte(`{"message": "Internal Server Error"}`))
+ }),
+ ),
+ ),
+ requestArgs: map[string]interface{}{},
+ expectError: true,
+ expectedErrMsg: "failed to list global security advisories",
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ // Setup client with mock
+ client := github.NewClient(tc.mockedClient)
+ _, handler := ListGlobalSecurityAdvisories(stubGetClientFn(client), translations.NullTranslationHelper)
+
+ // Create call request
+ request := createMCPRequest(tc.requestArgs)
+
+ // Call handler
+ result, err := handler(context.Background(), request)
+
+ // Verify results
+ if tc.expectError {
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), tc.expectedErrMsg)
+ return
+ }
+
+ require.NoError(t, err)
+
+ // Parse the result and get the text content if no error
+ textContent := getTextResult(t, result)
+
+ // Unmarshal and verify the result
+ var returnedAdvisories []*github.GlobalSecurityAdvisory
+ err = json.Unmarshal([]byte(textContent.Text), &returnedAdvisories)
+ assert.NoError(t, err)
+ assert.Len(t, returnedAdvisories, len(tc.expectedAdvisories))
+ for i, advisory := range returnedAdvisories {
+ assert.Equal(t, *tc.expectedAdvisories[i].GHSAID, *advisory.GHSAID)
+ assert.Equal(t, *tc.expectedAdvisories[i].Summary, *advisory.Summary)
+ assert.Equal(t, *tc.expectedAdvisories[i].Description, *advisory.Description)
+ assert.Equal(t, *tc.expectedAdvisories[i].Severity, *advisory.Severity)
+ }
+ })
+ }
+}
+
+func Test_GetGlobalSecurityAdvisory(t *testing.T) {
+ mockClient := github.NewClient(nil)
+ tool, _ := GetGlobalSecurityAdvisory(stubGetClientFn(mockClient), translations.NullTranslationHelper)
+
+ assert.Equal(t, "get_global_security_advisory", tool.Name)
+ assert.NotEmpty(t, tool.Description)
+ assert.Contains(t, tool.InputSchema.Properties, "ghsaId")
+ assert.ElementsMatch(t, tool.InputSchema.Required, []string{"ghsaId"})
+
+ // Setup mock advisory for success case
+ mockAdvisory := &github.GlobalSecurityAdvisory{
+ SecurityAdvisory: github.SecurityAdvisory{
+ GHSAID: github.Ptr("GHSA-xxxx-xxxx-xxxx"),
+ Summary: github.Ptr("Test advisory"),
+ Description: github.Ptr("This is a test advisory."),
+ Severity: github.Ptr("high"),
+ },
+ }
+
+ tests := []struct {
+ name string
+ mockedClient *http.Client
+ requestArgs map[string]interface{}
+ expectError bool
+ expectedAdvisory *github.GlobalSecurityAdvisory
+ expectedErrMsg string
+ }{
+ {
+ name: "successful advisory fetch",
+ mockedClient: mock.NewMockedHTTPClient(
+ mock.WithRequestMatch(
+ mock.GetAdvisoriesByGhsaId,
+ mockAdvisory,
+ ),
+ ),
+ requestArgs: map[string]interface{}{
+ "ghsaId": "GHSA-xxxx-xxxx-xxxx",
+ },
+ expectError: false,
+ expectedAdvisory: mockAdvisory,
+ },
+ {
+ name: "invalid ghsaId format",
+ mockedClient: mock.NewMockedHTTPClient(
+ mock.WithRequestMatchHandler(
+ mock.GetAdvisoriesByGhsaId,
+ http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(http.StatusBadRequest)
+ _, _ = w.Write([]byte(`{"message": "Bad Request"}`))
+ }),
+ ),
+ ),
+ requestArgs: map[string]interface{}{
+ "ghsaId": "invalid-ghsa-id",
+ },
+ expectError: true,
+ expectedErrMsg: "failed to get advisory",
+ },
+ {
+ name: "advisory not found",
+ mockedClient: mock.NewMockedHTTPClient(
+ mock.WithRequestMatchHandler(
+ mock.GetAdvisoriesByGhsaId,
+ http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(http.StatusNotFound)
+ _, _ = w.Write([]byte(`{"message": "Not Found"}`))
+ }),
+ ),
+ ),
+ requestArgs: map[string]interface{}{
+ "ghsaId": "GHSA-xxxx-xxxx-xxxx",
+ },
+ expectError: true,
+ expectedErrMsg: "failed to get advisory",
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ // Setup client with mock
+ client := github.NewClient(tc.mockedClient)
+ _, handler := GetGlobalSecurityAdvisory(stubGetClientFn(client), translations.NullTranslationHelper)
+
+ // Create call request
+ request := createMCPRequest(tc.requestArgs)
+
+ // Call handler
+ result, err := handler(context.Background(), request)
+
+ // Verify results
+ if tc.expectError {
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), tc.expectedErrMsg)
+ return
+ }
+
+ require.NoError(t, err)
+
+ // Parse the result and get the text content if no error
+ textContent := getTextResult(t, result)
+
+ // Verify the result
+ assert.Contains(t, textContent.Text, *tc.expectedAdvisory.GHSAID)
+ assert.Contains(t, textContent.Text, *tc.expectedAdvisory.Summary)
+ assert.Contains(t, textContent.Text, *tc.expectedAdvisory.Description)
+ assert.Contains(t, textContent.Text, *tc.expectedAdvisory.Severity)
+ })
+ }
+}
+
+func Test_ListRepositorySecurityAdvisories(t *testing.T) {
+ // Verify tool definition once
+ mockClient := github.NewClient(nil)
+ tool, _ := ListRepositorySecurityAdvisories(stubGetClientFn(mockClient), translations.NullTranslationHelper)
+
+ assert.Equal(t, "list_repository_security_advisories", tool.Name)
+ 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, "direction")
+ assert.Contains(t, tool.InputSchema.Properties, "sort")
+ assert.Contains(t, tool.InputSchema.Properties, "state")
+ assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo"})
+
+ // Local endpoint pattern for repository security advisories
+ var GetReposSecurityAdvisoriesByOwnerByRepo = mock.EndpointPattern{
+ Pattern: "/repos/{owner}/{repo}/security-advisories",
+ Method: "GET",
+ }
+
+ // Setup mock advisories for success cases
+ adv1 := &github.SecurityAdvisory{
+ GHSAID: github.Ptr("GHSA-1111-1111-1111"),
+ Summary: github.Ptr("Repo advisory one"),
+ Description: github.Ptr("First repo advisory."),
+ Severity: github.Ptr("high"),
+ }
+ adv2 := &github.SecurityAdvisory{
+ GHSAID: github.Ptr("GHSA-2222-2222-2222"),
+ Summary: github.Ptr("Repo advisory two"),
+ Description: github.Ptr("Second repo advisory."),
+ Severity: github.Ptr("medium"),
+ }
+
+ tests := []struct {
+ name string
+ mockedClient *http.Client
+ requestArgs map[string]interface{}
+ expectError bool
+ expectedAdvisories []*github.SecurityAdvisory
+ expectedErrMsg string
+ }{
+ {
+ name: "successful advisories listing (no filters)",
+ mockedClient: mock.NewMockedHTTPClient(
+ mock.WithRequestMatchHandler(
+ GetReposSecurityAdvisoriesByOwnerByRepo,
+ expect(t, expectations{
+ path: "/repos/owner/repo/security-advisories",
+ queryParams: map[string]string{},
+ }).andThen(
+ mockResponse(t, http.StatusOK, []*github.SecurityAdvisory{adv1, adv2}),
+ ),
+ ),
+ ),
+ requestArgs: map[string]interface{}{
+ "owner": "owner",
+ "repo": "repo",
+ },
+ expectError: false,
+ expectedAdvisories: []*github.SecurityAdvisory{adv1, adv2},
+ },
+ {
+ name: "successful advisories listing with filters",
+ mockedClient: mock.NewMockedHTTPClient(
+ mock.WithRequestMatchHandler(
+ GetReposSecurityAdvisoriesByOwnerByRepo,
+ expect(t, expectations{
+ path: "/repos/octo/hello-world/security-advisories",
+ queryParams: map[string]string{
+ "direction": "desc",
+ "sort": "updated",
+ "state": "published",
+ },
+ }).andThen(
+ mockResponse(t, http.StatusOK, []*github.SecurityAdvisory{adv1}),
+ ),
+ ),
+ ),
+ requestArgs: map[string]interface{}{
+ "owner": "octo",
+ "repo": "hello-world",
+ "direction": "desc",
+ "sort": "updated",
+ "state": "published",
+ },
+ expectError: false,
+ expectedAdvisories: []*github.SecurityAdvisory{adv1},
+ },
+ {
+ name: "advisories listing fails",
+ mockedClient: mock.NewMockedHTTPClient(
+ mock.WithRequestMatchHandler(
+ GetReposSecurityAdvisoriesByOwnerByRepo,
+ expect(t, expectations{
+ path: "/repos/owner/repo/security-advisories",
+ queryParams: map[string]string{},
+ }).andThen(
+ mockResponse(t, http.StatusInternalServerError, map[string]string{"message": "Internal Server Error"}),
+ ),
+ ),
+ ),
+ requestArgs: map[string]interface{}{
+ "owner": "owner",
+ "repo": "repo",
+ },
+ expectError: true,
+ expectedErrMsg: "failed to list repository security advisories",
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ client := github.NewClient(tc.mockedClient)
+ _, handler := ListRepositorySecurityAdvisories(stubGetClientFn(client), translations.NullTranslationHelper)
+
+ request := createMCPRequest(tc.requestArgs)
+
+ result, err := handler(context.Background(), request)
+
+ if tc.expectError {
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), tc.expectedErrMsg)
+ return
+ }
+
+ require.NoError(t, err)
+
+ textContent := getTextResult(t, result)
+
+ var returnedAdvisories []*github.SecurityAdvisory
+ err = json.Unmarshal([]byte(textContent.Text), &returnedAdvisories)
+ assert.NoError(t, err)
+ assert.Len(t, returnedAdvisories, len(tc.expectedAdvisories))
+ for i, advisory := range returnedAdvisories {
+ assert.Equal(t, *tc.expectedAdvisories[i].GHSAID, *advisory.GHSAID)
+ assert.Equal(t, *tc.expectedAdvisories[i].Summary, *advisory.Summary)
+ assert.Equal(t, *tc.expectedAdvisories[i].Description, *advisory.Description)
+ assert.Equal(t, *tc.expectedAdvisories[i].Severity, *advisory.Severity)
+ }
+ })
+ }
+}
+
+func Test_ListOrgRepositorySecurityAdvisories(t *testing.T) {
+ // Verify tool definition once
+ mockClient := github.NewClient(nil)
+ tool, _ := ListOrgRepositorySecurityAdvisories(stubGetClientFn(mockClient), translations.NullTranslationHelper)
+
+ assert.Equal(t, "list_org_repository_security_advisories", tool.Name)
+ assert.NotEmpty(t, tool.Description)
+ assert.Contains(t, tool.InputSchema.Properties, "org")
+ assert.Contains(t, tool.InputSchema.Properties, "direction")
+ assert.Contains(t, tool.InputSchema.Properties, "sort")
+ assert.Contains(t, tool.InputSchema.Properties, "state")
+ assert.ElementsMatch(t, tool.InputSchema.Required, []string{"org"})
+
+ // Endpoint pattern for org repository security advisories
+ var GetOrgsSecurityAdvisoriesByOrg = mock.EndpointPattern{
+ Pattern: "/orgs/{org}/security-advisories",
+ Method: "GET",
+ }
+
+ adv1 := &github.SecurityAdvisory{
+ GHSAID: github.Ptr("GHSA-aaaa-bbbb-cccc"),
+ Summary: github.Ptr("Org repo advisory 1"),
+ Description: github.Ptr("First advisory"),
+ Severity: github.Ptr("low"),
+ }
+ adv2 := &github.SecurityAdvisory{
+ GHSAID: github.Ptr("GHSA-dddd-eeee-ffff"),
+ Summary: github.Ptr("Org repo advisory 2"),
+ Description: github.Ptr("Second advisory"),
+ Severity: github.Ptr("critical"),
+ }
+
+ tests := []struct {
+ name string
+ mockedClient *http.Client
+ requestArgs map[string]interface{}
+ expectError bool
+ expectedAdvisories []*github.SecurityAdvisory
+ expectedErrMsg string
+ }{
+ {
+ name: "successful listing (no filters)",
+ mockedClient: mock.NewMockedHTTPClient(
+ mock.WithRequestMatchHandler(
+ GetOrgsSecurityAdvisoriesByOrg,
+ expect(t, expectations{
+ path: "/orgs/octo/security-advisories",
+ queryParams: map[string]string{},
+ }).andThen(
+ mockResponse(t, http.StatusOK, []*github.SecurityAdvisory{adv1, adv2}),
+ ),
+ ),
+ ),
+ requestArgs: map[string]interface{}{
+ "org": "octo",
+ },
+ expectError: false,
+ expectedAdvisories: []*github.SecurityAdvisory{adv1, adv2},
+ },
+ {
+ name: "successful listing with filters",
+ mockedClient: mock.NewMockedHTTPClient(
+ mock.WithRequestMatchHandler(
+ GetOrgsSecurityAdvisoriesByOrg,
+ expect(t, expectations{
+ path: "/orgs/octo/security-advisories",
+ queryParams: map[string]string{
+ "direction": "asc",
+ "sort": "created",
+ "state": "triage",
+ },
+ }).andThen(
+ mockResponse(t, http.StatusOK, []*github.SecurityAdvisory{adv1}),
+ ),
+ ),
+ ),
+ requestArgs: map[string]interface{}{
+ "org": "octo",
+ "direction": "asc",
+ "sort": "created",
+ "state": "triage",
+ },
+ expectError: false,
+ expectedAdvisories: []*github.SecurityAdvisory{adv1},
+ },
+ {
+ name: "listing fails",
+ mockedClient: mock.NewMockedHTTPClient(
+ mock.WithRequestMatchHandler(
+ GetOrgsSecurityAdvisoriesByOrg,
+ expect(t, expectations{
+ path: "/orgs/octo/security-advisories",
+ queryParams: map[string]string{},
+ }).andThen(
+ mockResponse(t, http.StatusForbidden, map[string]string{"message": "Forbidden"}),
+ ),
+ ),
+ ),
+ requestArgs: map[string]interface{}{
+ "org": "octo",
+ },
+ expectError: true,
+ expectedErrMsg: "failed to list organization repository security advisories",
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ client := github.NewClient(tc.mockedClient)
+ _, handler := ListOrgRepositorySecurityAdvisories(stubGetClientFn(client), translations.NullTranslationHelper)
+
+ request := createMCPRequest(tc.requestArgs)
+
+ result, err := handler(context.Background(), request)
+
+ if tc.expectError {
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), tc.expectedErrMsg)
+ return
+ }
+
+ require.NoError(t, err)
+
+ textContent := getTextResult(t, result)
+
+ var returnedAdvisories []*github.SecurityAdvisory
+ err = json.Unmarshal([]byte(textContent.Text), &returnedAdvisories)
+ assert.NoError(t, err)
+ assert.Len(t, returnedAdvisories, len(tc.expectedAdvisories))
+ for i, advisory := range returnedAdvisories {
+ assert.Equal(t, *tc.expectedAdvisories[i].GHSAID, *advisory.GHSAID)
+ assert.Equal(t, *tc.expectedAdvisories[i].Summary, *advisory.Summary)
+ assert.Equal(t, *tc.expectedAdvisories[i].Description, *advisory.Description)
+ assert.Equal(t, *tc.expectedAdvisories[i].Severity, *advisory.Severity)
+ }
+ })
+ }
+}
diff --git a/pkg/github/server.go b/pkg/github/server.go
index ea476e3ac..16d28643c 100644
--- a/pkg/github/server.go
+++ b/pkg/github/server.go
@@ -5,7 +5,7 @@ import (
"errors"
"fmt"
- "github.com/google/go-github/v73/github"
+ "github.com/google/go-github/v74/github"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
)
@@ -144,6 +144,21 @@ func OptionalIntParamWithDefault(r mcp.CallToolRequest, p string, d int) (int, e
return v, nil
}
+// OptionalBoolParamWithDefault is a helper function that can be used to fetch a requested parameter from the request
+// similar to optionalBoolParam, but it also takes a default value.
+func OptionalBoolParamWithDefault(r mcp.CallToolRequest, p string, d bool) (bool, error) {
+ args := r.GetArguments()
+ _, ok := args[p]
+ v, err := OptionalParam[bool](r, p)
+ if err != nil {
+ return false, err
+ }
+ if !ok {
+ return d, nil
+ }
+ return v, nil
+}
+
// OptionalStringArrayParam is a helper function that can be used to fetch a requested parameter from the request.
// It does the following checks:
// 1. Checks if the parameter is present in the request, if not, it returns its zero-value
@@ -174,9 +189,7 @@ func OptionalStringArrayParam(r mcp.CallToolRequest, p string) ([]string, error)
}
}
-// WithPagination returns a ToolOption that adds "page" and "perPage" parameters to the tool.
-// The "page" parameter is optional, min 1.
-// The "perPage" parameter is optional, min 1, max 100. If unset, defaults to 30.
+// 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
func WithPagination() mcp.ToolOption {
return func(tool *mcp.Tool) {
@@ -193,12 +206,49 @@ func WithPagination() mcp.ToolOption {
}
}
+// 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 {
+ 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),
+ )(tool)
+
+ mcp.WithString("after",
+ mcp.Description("Cursor for pagination. Use the endCursor from the previous page's PageInfo for GraphQL APIs."),
+ )(tool)
+ }
+}
+
+// WithCursorPagination adds only cursor-based pagination parameters to a tool (no page parameter).
+func WithCursorPagination() mcp.ToolOption {
+ return func(tool *mcp.Tool) {
+ mcp.WithNumber("perPage",
+ mcp.Description("Results per page for pagination (min 1, max 100)"),
+ mcp.Min(1),
+ mcp.Max(100),
+ )(tool)
+
+ mcp.WithString("after",
+ mcp.Description("Cursor for pagination. Use the endCursor from the previous page's PageInfo for GraphQL APIs."),
+ )(tool)
+ }
+}
+
type PaginationParams struct {
- page int
- perPage int
+ Page int
+ PerPage int
+ After string
}
-// OptionalPaginationParams returns the "page" and "perPage" parameters from the request,
+// 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
@@ -212,12 +262,77 @@ func OptionalPaginationParams(r mcp.CallToolRequest) (PaginationParams, error) {
if err != nil {
return PaginationParams{}, err
}
+ after, err := OptionalParam[string](r, "after")
+ if err != nil {
+ return PaginationParams{}, err
+ }
return PaginationParams{
- page: page,
- perPage: perPage,
+ Page: page,
+ PerPage: perPage,
+ After: after,
+ }, nil
+}
+
+// OptionalCursorPaginationParams returns the "perPage" and "after" parameters from the request,
+// without the "page" parameter, suitable for cursor-based pagination only.
+func OptionalCursorPaginationParams(r mcp.CallToolRequest) (CursorPaginationParams, error) {
+ perPage, err := OptionalIntParamWithDefault(r, "perPage", 30)
+ if err != nil {
+ return CursorPaginationParams{}, err
+ }
+ after, err := OptionalParam[string](r, "after")
+ if err != nil {
+ return CursorPaginationParams{}, err
+ }
+ return CursorPaginationParams{
+ PerPage: perPage,
+ After: after,
}, nil
}
+type CursorPaginationParams struct {
+ PerPage int
+ After string
+}
+
+// ToGraphQLParams converts cursor pagination parameters to GraphQL-specific parameters.
+func (p CursorPaginationParams) ToGraphQLParams() (*GraphQLPaginationParams, error) {
+ if p.PerPage > 100 {
+ return nil, fmt.Errorf("perPage value %d exceeds maximum of 100", p.PerPage)
+ }
+ if p.PerPage < 0 {
+ return nil, fmt.Errorf("perPage value %d cannot be negative", p.PerPage)
+ }
+ first := int32(p.PerPage)
+
+ var after *string
+ if p.After != "" {
+ after = &p.After
+ }
+
+ return &GraphQLPaginationParams{
+ First: &first,
+ After: after,
+ }, nil
+}
+
+type GraphQLPaginationParams struct {
+ First *int32
+ After *string
+}
+
+// ToGraphQLParams converts REST API pagination parameters to GraphQL-specific parameters.
+// This converts page/perPage to first parameter for GraphQL queries.
+// If After is provided, it takes precedence over page-based pagination.
+func (p PaginationParams) ToGraphQLParams() (*GraphQLPaginationParams, error) {
+ // Convert to CursorPaginationParams and delegate to avoid duplication
+ cursor := CursorPaginationParams{
+ PerPage: p.PerPage,
+ After: p.After,
+ }
+ return cursor.ToGraphQLParams()
+}
+
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 6353f254d..f38c4dc01 100644
--- a/pkg/github/server_test.go
+++ b/pkg/github/server_test.go
@@ -9,7 +9,7 @@ import (
"testing"
"github.com/github/github-mcp-server/pkg/raw"
- "github.com/google/go-github/v73/github"
+ "github.com/google/go-github/v74/github"
"github.com/shurcooL/githubv4"
"github.com/stretchr/testify/assert"
)
@@ -489,8 +489,8 @@ func TestOptionalPaginationParams(t *testing.T) {
name: "no pagination parameters, default values",
params: map[string]any{},
expected: PaginationParams{
- page: 1,
- perPage: 30,
+ Page: 1,
+ PerPage: 30,
},
expectError: false,
},
@@ -500,8 +500,8 @@ func TestOptionalPaginationParams(t *testing.T) {
"page": float64(2),
},
expected: PaginationParams{
- page: 2,
- perPage: 30,
+ Page: 2,
+ PerPage: 30,
},
expectError: false,
},
@@ -511,8 +511,8 @@ func TestOptionalPaginationParams(t *testing.T) {
"perPage": float64(50),
},
expected: PaginationParams{
- page: 1,
- perPage: 50,
+ Page: 1,
+ PerPage: 50,
},
expectError: false,
},
@@ -523,8 +523,8 @@ func TestOptionalPaginationParams(t *testing.T) {
"perPage": float64(50),
},
expected: PaginationParams{
- page: 2,
- perPage: 50,
+ Page: 2,
+ PerPage: 50,
},
expectError: false,
},
diff --git a/pkg/github/tools.go b/pkg/github/tools.go
index bd349171d..728d78097 100644
--- a/pkg/github/tools.go
+++ b/pkg/github/tools.go
@@ -6,7 +6,7 @@ import (
"github.com/github/github-mcp-server/pkg/raw"
"github.com/github/github-mcp-server/pkg/toolsets"
"github.com/github/github-mcp-server/pkg/translations"
- "github.com/google/go-github/v73/github"
+ "github.com/google/go-github/v74/github"
"github.com/mark3labs/mcp-go/server"
"github.com/shurcooL/githubv4"
)
@@ -16,7 +16,7 @@ type GetGQLClientFn func(context.Context) (*githubv4.Client, error)
var DefaultTools = []string{"all"}
-func DefaultToolsetGroup(readOnly bool, getClient GetClientFn, getGQLClient GetGQLClientFn, getRawClient raw.GetRawClientFn, t translations.TranslationHelperFunc) *toolsets.ToolsetGroup {
+func DefaultToolsetGroup(readOnly bool, getClient GetClientFn, getGQLClient GetGQLClientFn, getRawClient raw.GetRawClientFn, t translations.TranslationHelperFunc, contentWindowSize int) *toolsets.ToolsetGroup {
tsg := toolsets.NewToolsetGroup(readOnly)
// Define all available features with their default state (disabled)
@@ -31,6 +31,9 @@ func DefaultToolsetGroup(readOnly bool, getClient GetClientFn, getGQLClient GetG
toolsets.NewServerTool(ListBranches(getClient, t)),
toolsets.NewServerTool(ListTags(getClient, t)),
toolsets.NewServerTool(GetTag(getClient, t)),
+ toolsets.NewServerTool(ListReleases(getClient, t)),
+ toolsets.NewServerTool(GetLatestRelease(getClient, t)),
+ toolsets.NewServerTool(GetReleaseByTag(getClient, t)),
).
AddWriteTools(
toolsets.NewServerTool(CreateOrUpdateFile(getClient, t)),
@@ -51,15 +54,23 @@ func DefaultToolsetGroup(readOnly bool, getClient GetClientFn, getGQLClient GetG
AddReadTools(
toolsets.NewServerTool(GetIssue(getClient, t)),
toolsets.NewServerTool(SearchIssues(getClient, t)),
- toolsets.NewServerTool(ListIssues(getClient, t)),
+ toolsets.NewServerTool(ListIssues(getGQLClient, t)),
toolsets.NewServerTool(GetIssueComments(getClient, t)),
+ toolsets.NewServerTool(ListIssueTypes(getClient, t)),
+ toolsets.NewServerTool(ListSubIssues(getClient, t)),
).
AddWriteTools(
toolsets.NewServerTool(CreateIssue(getClient, t)),
toolsets.NewServerTool(AddIssueComment(getClient, t)),
toolsets.NewServerTool(UpdateIssue(getClient, t)),
toolsets.NewServerTool(AssignCopilotToIssue(getGQLClient, t)),
- ).AddPrompts(toolsets.NewServerPrompt(AssignCodingAgentPrompt(t)))
+ toolsets.NewServerTool(AddSubIssue(getClient, t)),
+ toolsets.NewServerTool(RemoveSubIssue(getClient, t)),
+ toolsets.NewServerTool(ReprioritizeSubIssue(getClient, t)),
+ ).AddPrompts(
+ toolsets.NewServerPrompt(AssignCodingAgentPrompt(t)),
+ toolsets.NewServerPrompt(IssueToFixWorkflowPrompt(t)),
+ )
users := toolsets.NewToolset("users", "GitHub User related tools").
AddReadTools(
toolsets.NewServerTool(SearchUsers(getClient, t)),
@@ -83,7 +94,7 @@ func DefaultToolsetGroup(readOnly bool, getClient GetClientFn, getGQLClient GetG
toolsets.NewServerTool(MergePullRequest(getClient, t)),
toolsets.NewServerTool(UpdatePullRequestBranch(getClient, t)),
toolsets.NewServerTool(CreatePullRequest(getClient, t)),
- toolsets.NewServerTool(UpdatePullRequest(getClient, t)),
+ toolsets.NewServerTool(UpdatePullRequest(getClient, getGQLClient, t)),
toolsets.NewServerTool(RequestCopilotReview(getClient, t)),
// Reviews
@@ -136,7 +147,7 @@ func DefaultToolsetGroup(readOnly bool, getClient GetClientFn, getGQLClient GetG
toolsets.NewServerTool(GetWorkflowRun(getClient, t)),
toolsets.NewServerTool(GetWorkflowRunLogs(getClient, t)),
toolsets.NewServerTool(ListWorkflowJobs(getClient, t)),
- toolsets.NewServerTool(GetJobLogs(getClient, t)),
+ toolsets.NewServerTool(GetJobLogs(getClient, t, contentWindowSize)),
toolsets.NewServerTool(ListWorkflowRunArtifacts(getClient, t)),
toolsets.NewServerTool(DownloadWorkflowRunArtifact(getClient, t)),
toolsets.NewServerTool(GetWorkflowRunUsage(getClient, t)),
@@ -149,12 +160,31 @@ func DefaultToolsetGroup(readOnly bool, getClient GetClientFn, getGQLClient GetG
toolsets.NewServerTool(DeleteWorkflowRunLogs(getClient, t)),
)
+ securityAdvisories := toolsets.NewToolset("security_advisories", "Security advisories related tools").
+ AddReadTools(
+ toolsets.NewServerTool(ListGlobalSecurityAdvisories(getClient, t)),
+ toolsets.NewServerTool(GetGlobalSecurityAdvisory(getClient, t)),
+ toolsets.NewServerTool(ListRepositorySecurityAdvisories(getClient, t)),
+ toolsets.NewServerTool(ListOrgRepositorySecurityAdvisories(getClient, t)),
+ )
+
// Keep experiments alive so the system doesn't error out when it's always enabled
experiments := toolsets.NewToolset("experiments", "Experimental features that are not considered stable yet")
contextTools := toolsets.NewToolset("context", "Tools that provide context about the current user and GitHub context you are operating in").
AddReadTools(
toolsets.NewServerTool(GetMe(getClient, t)),
+ toolsets.NewServerTool(GetTeams(getClient, getGQLClient, t)),
+ toolsets.NewServerTool(GetTeamMembers(getGQLClient, t)),
+ )
+
+ gists := toolsets.NewToolset("gists", "GitHub Gist related tools").
+ AddReadTools(
+ toolsets.NewServerTool(ListGists(getClient, t)),
+ ).
+ AddWriteTools(
+ toolsets.NewServerTool(CreateGist(getClient, t)),
+ toolsets.NewServerTool(UpdateGist(getClient, t)),
)
// Add toolsets to the group
@@ -171,6 +201,8 @@ func DefaultToolsetGroup(readOnly bool, getClient GetClientFn, getGQLClient GetG
tsg.AddToolset(notifications)
tsg.AddToolset(experiments)
tsg.AddToolset(discussions)
+ tsg.AddToolset(gists)
+ tsg.AddToolset(securityAdvisories)
return tsg
}
diff --git a/pkg/github/workflow_prompts.go b/pkg/github/workflow_prompts.go
new file mode 100644
index 000000000..42b6d51c8
--- /dev/null
+++ b/pkg/github/workflow_prompts.go
@@ -0,0 +1,77 @@
+package github
+
+import (
+ "context"
+ "fmt"
+
+ "github.com/github/github-mcp-server/pkg/translations"
+ "github.com/mark3labs/mcp-go/mcp"
+ "github.com/mark3labs/mcp-go/server"
+)
+
+// IssueToFixWorkflowPrompt provides a guided workflow for creating an issue and then generating a PR to fix it
+func IssueToFixWorkflowPrompt(t translations.TranslationHelperFunc) (tool mcp.Prompt, handler server.PromptHandlerFunc) {
+ return mcp.NewPrompt("IssueToFixWorkflow",
+ mcp.WithPromptDescription(t("PROMPT_ISSUE_TO_FIX_WORKFLOW_DESCRIPTION", "Create an issue for a problem and then generate a pull request to fix it")),
+ mcp.WithArgument("owner", mcp.ArgumentDescription("Repository owner"), mcp.RequiredArgument()),
+ mcp.WithArgument("repo", mcp.ArgumentDescription("Repository name"), mcp.RequiredArgument()),
+ mcp.WithArgument("title", mcp.ArgumentDescription("Issue title"), mcp.RequiredArgument()),
+ mcp.WithArgument("description", mcp.ArgumentDescription("Issue description"), mcp.RequiredArgument()),
+ mcp.WithArgument("labels", mcp.ArgumentDescription("Comma-separated list of labels to apply (optional)")),
+ mcp.WithArgument("assignees", mcp.ArgumentDescription("Comma-separated list of assignees (optional)")),
+ ), func(_ context.Context, request mcp.GetPromptRequest) (*mcp.GetPromptResult, error) {
+ owner := request.Params.Arguments["owner"]
+ repo := request.Params.Arguments["repo"]
+ title := request.Params.Arguments["title"]
+ description := request.Params.Arguments["description"]
+
+ labels := ""
+ if l, exists := request.Params.Arguments["labels"]; exists {
+ labels = fmt.Sprintf("%v", l)
+ }
+
+ assignees := ""
+ if a, exists := request.Params.Arguments["assignees"]; exists {
+ assignees = fmt.Sprintf("%v", a)
+ }
+
+ messages := []mcp.PromptMessage{
+ {
+ Role: "user",
+ Content: mcp.NewTextContent("You are a development workflow assistant helping to create GitHub issues and generate corresponding pull requests to fix them. You should: 1) Create a well-structured issue with clear problem description, 2) Assign it to Copilot coding agent to generate a solution, and 3) Monitor the PR creation process."),
+ },
+ {
+ Role: "user",
+ Content: mcp.NewTextContent(fmt.Sprintf("I need to create an issue titled '%s' in %s/%s and then have a PR generated to fix it. The issue description is: %s%s%s",
+ title, owner, repo, description,
+ func() string {
+ if labels != "" {
+ return fmt.Sprintf("\n\nLabels to apply: %s", labels)
+ }
+ return ""
+ }(),
+ func() string {
+ if assignees != "" {
+ return fmt.Sprintf("\nAssignees: %s", assignees)
+ }
+ return ""
+ }())),
+ },
+ {
+ Role: "assistant",
+ Content: mcp.NewTextContent(fmt.Sprintf("I'll help you create the issue '%s' in %s/%s and then coordinate with Copilot to generate a fix. Let me start by creating the issue with the provided details.", title, owner, repo)),
+ },
+ {
+ Role: "user",
+ Content: mcp.NewTextContent("Perfect! Please:\n1. Create the issue with the title, description, labels, and assignees\n2. Once created, assign it to Copilot coding agent to generate a solution\n3. Monitor the process and let me know when the PR is ready for review"),
+ },
+ {
+ Role: "assistant",
+ Content: mcp.NewTextContent("Excellent plan! Here's what I'll do:\n\n1. β
Create the issue with all specified details\n2. π€ Assign to Copilot coding agent for automated fix\n3. π Monitor progress and notify when PR is created\n4. π Provide PR details for your review\n\nLet me start by creating the issue."),
+ },
+ }
+ return &mcp.GetPromptResult{
+ Messages: messages,
+ }, nil
+ }
+}
diff --git a/pkg/log/io.go b/pkg/log/io.go
index de2210278..44b8dc17a 100644
--- a/pkg/log/io.go
+++ b/pkg/log/io.go
@@ -3,7 +3,7 @@ package log
import (
"io"
- log "github.com/sirupsen/logrus"
+ "log/slog"
)
// IOLogger is a wrapper around io.Reader and io.Writer that can be used
@@ -11,11 +11,11 @@ import (
type IOLogger struct {
reader io.Reader
writer io.Writer
- logger *log.Logger
+ logger *slog.Logger
}
// NewIOLogger creates a new IOLogger instance
-func NewIOLogger(r io.Reader, w io.Writer, logger *log.Logger) *IOLogger {
+func NewIOLogger(r io.Reader, w io.Writer, logger *slog.Logger) *IOLogger {
return &IOLogger{
reader: r,
writer: w,
@@ -30,7 +30,7 @@ func (l *IOLogger) Read(p []byte) (n int, err error) {
}
n, err = l.reader.Read(p)
if n > 0 {
- l.logger.Infof("[stdin]: received %d bytes: %s", n, string(p[:n]))
+ l.logger.Info("[stdin]: received bytes", "count", n, "data", string(p[:n]))
}
return n, err
}
@@ -40,6 +40,6 @@ func (l *IOLogger) Write(p []byte) (n int, err error) {
if l.writer == nil {
return 0, io.ErrClosedPipe
}
- l.logger.Infof("[stdout]: sending %d bytes: %s", len(p), string(p))
+ l.logger.Info("[stdout]: sending bytes", "count", len(p), "data", string(p))
return l.writer.Write(p)
}
diff --git a/pkg/log/io_test.go b/pkg/log/io_test.go
index 0d0cd8959..2661de164 100644
--- a/pkg/log/io_test.go
+++ b/pkg/log/io_test.go
@@ -5,7 +5,8 @@ import (
"strings"
"testing"
- log "github.com/sirupsen/logrus"
+ "log/slog"
+
"github.com/stretchr/testify/assert"
)
@@ -17,11 +18,7 @@ func TestLoggedReadWriter(t *testing.T) {
// Create logger with buffer to capture output
var logBuffer bytes.Buffer
- logger := log.New()
- logger.SetOutput(&logBuffer)
- logger.SetFormatter(&log.TextFormatter{
- DisableTimestamp: true,
- })
+ logger := slog.New(slog.NewTextHandler(&logBuffer, &slog.HandlerOptions{ReplaceAttr: removeTimeAttr}))
lrw := NewIOLogger(reader, nil, logger)
@@ -44,11 +41,7 @@ func TestLoggedReadWriter(t *testing.T) {
// Create logger with buffer to capture output
var logBuffer bytes.Buffer
- logger := log.New()
- logger.SetOutput(&logBuffer)
- logger.SetFormatter(&log.TextFormatter{
- DisableTimestamp: true,
- })
+ logger := slog.New(slog.NewTextHandler(&logBuffer, &slog.HandlerOptions{ReplaceAttr: removeTimeAttr}))
lrw := NewIOLogger(nil, &writeBuffer, logger)
@@ -63,3 +56,10 @@ func TestLoggedReadWriter(t *testing.T) {
assert.Contains(t, logBuffer.String(), outputData)
})
}
+
+func removeTimeAttr(groups []string, a slog.Attr) slog.Attr {
+ if a.Key == slog.TimeKey && len(groups) == 0 {
+ return slog.Attr{}
+ }
+ return a
+}
diff --git a/pkg/raw/raw.go b/pkg/raw/raw.go
index af669c905..ddf88b428 100644
--- a/pkg/raw/raw.go
+++ b/pkg/raw/raw.go
@@ -6,7 +6,7 @@ import (
"net/http"
"net/url"
- gogithub "github.com/google/go-github/v73/github"
+ gogithub "github.com/google/go-github/v74/github"
)
// GetRawClientFn is a function type that returns a RawClient instance.
diff --git a/pkg/raw/raw_test.go b/pkg/raw/raw_test.go
index 18a48130d..4e5bdce7a 100644
--- a/pkg/raw/raw_test.go
+++ b/pkg/raw/raw_test.go
@@ -6,7 +6,7 @@ import (
"net/url"
"testing"
- "github.com/google/go-github/v73/github"
+ "github.com/google/go-github/v74/github"
"github.com/migueleliasweb/go-github-mock/src/mock"
"github.com/stretchr/testify/require"
)
diff --git a/pkg/toolsets/toolsets.go b/pkg/toolsets/toolsets.go
index 5d503b742..85cd5d841 100644
--- a/pkg/toolsets/toolsets.go
+++ b/pkg/toolsets/toolsets.go
@@ -33,32 +33,20 @@ func NewServerTool(tool mcp.Tool, handler server.ToolHandlerFunc) server.ServerT
return server.ServerTool{Tool: tool, Handler: handler}
}
-func NewServerResourceTemplate(resourceTemplate mcp.ResourceTemplate, handler server.ResourceTemplateHandlerFunc) ServerResourceTemplate {
- return ServerResourceTemplate{
- resourceTemplate: resourceTemplate,
- handler: handler,
+func NewServerResourceTemplate(resourceTemplate mcp.ResourceTemplate, handler server.ResourceTemplateHandlerFunc) server.ServerResourceTemplate {
+ return server.ServerResourceTemplate{
+ Template: resourceTemplate,
+ Handler: handler,
}
}
-func NewServerPrompt(prompt mcp.Prompt, handler server.PromptHandlerFunc) ServerPrompt {
- return ServerPrompt{
+func NewServerPrompt(prompt mcp.Prompt, handler server.PromptHandlerFunc) server.ServerPrompt {
+ return server.ServerPrompt{
Prompt: prompt,
Handler: handler,
}
}
-// ServerResourceTemplate represents a resource template that can be registered with the MCP server.
-type ServerResourceTemplate struct {
- resourceTemplate mcp.ResourceTemplate
- handler server.ResourceTemplateHandlerFunc
-}
-
-// ServerPrompt represents a prompt that can be registered with the MCP server.
-type ServerPrompt struct {
- Prompt mcp.Prompt
- Handler server.PromptHandlerFunc
-}
-
// Toolset represents a collection of MCP functionality that can be enabled or disabled as a group.
type Toolset struct {
Name string
@@ -69,9 +57,9 @@ type Toolset struct {
readTools []server.ServerTool
// resources are not tools, but the community seems to be moving towards namespaces as a broader concept
// and in order to have multiple servers running concurrently, we want to avoid overlapping resources too.
- resourceTemplates []ServerResourceTemplate
+ resourceTemplates []server.ServerResourceTemplate
// prompts are also not tools but are namespaced similarly
- prompts []ServerPrompt
+ prompts []server.ServerPrompt
}
func (t *Toolset) GetActiveTools() []server.ServerTool {
@@ -105,24 +93,24 @@ func (t *Toolset) RegisterTools(s *server.MCPServer) {
}
}
-func (t *Toolset) AddResourceTemplates(templates ...ServerResourceTemplate) *Toolset {
+func (t *Toolset) AddResourceTemplates(templates ...server.ServerResourceTemplate) *Toolset {
t.resourceTemplates = append(t.resourceTemplates, templates...)
return t
}
-func (t *Toolset) AddPrompts(prompts ...ServerPrompt) *Toolset {
+func (t *Toolset) AddPrompts(prompts ...server.ServerPrompt) *Toolset {
t.prompts = append(t.prompts, prompts...)
return t
}
-func (t *Toolset) GetActiveResourceTemplates() []ServerResourceTemplate {
+func (t *Toolset) GetActiveResourceTemplates() []server.ServerResourceTemplate {
if !t.Enabled {
return nil
}
return t.resourceTemplates
}
-func (t *Toolset) GetAvailableResourceTemplates() []ServerResourceTemplate {
+func (t *Toolset) GetAvailableResourceTemplates() []server.ServerResourceTemplate {
return t.resourceTemplates
}
@@ -131,7 +119,7 @@ func (t *Toolset) RegisterResourcesTemplates(s *server.MCPServer) {
return
}
for _, resource := range t.resourceTemplates {
- s.AddResourceTemplate(resource.resourceTemplate, resource.handler)
+ s.AddResourceTemplate(resource.Template, resource.Handler)
}
}
diff --git a/third-party-licenses.darwin.md b/third-party-licenses.darwin.md
index 6a9f895cb..c279f72ea 100644
--- a/third-party-licenses.darwin.md
+++ b/third-party-licenses.darwin.md
@@ -7,26 +7,28 @@ The following open source dependencies are used to build the [github/github-mcp-
Some packages may only be included on certain architectures or operating systems.
+ - [github.com/bahlo/generic-list-go](https://pkg.go.dev/github.com/bahlo/generic-list-go) ([BSD-3-Clause](https://github.com/bahlo/generic-list-go/blob/v0.2.0/LICENSE))
+ - [github.com/buger/jsonparser](https://pkg.go.dev/github.com/buger/jsonparser) ([MIT](https://github.com/buger/jsonparser/blob/v1.1.1/LICENSE))
- [github.com/fsnotify/fsnotify](https://pkg.go.dev/github.com/fsnotify/fsnotify) ([BSD-3-Clause](https://github.com/fsnotify/fsnotify/blob/v1.8.0/LICENSE))
- [github.com/github/github-mcp-server](https://pkg.go.dev/github.com/github/github-mcp-server) ([MIT](https://github.com/github/github-mcp-server/blob/HEAD/LICENSE))
- [github.com/go-openapi/jsonpointer](https://pkg.go.dev/github.com/go-openapi/jsonpointer) ([Apache-2.0](https://github.com/go-openapi/jsonpointer/blob/v0.19.5/LICENSE))
- [github.com/go-openapi/swag](https://pkg.go.dev/github.com/go-openapi/swag) ([Apache-2.0](https://github.com/go-openapi/swag/blob/v0.21.1/LICENSE))
- [github.com/go-viper/mapstructure/v2](https://pkg.go.dev/github.com/go-viper/mapstructure/v2) ([MIT](https://github.com/go-viper/mapstructure/blob/v2.3.0/LICENSE))
- [github.com/google/go-github/v71/github](https://pkg.go.dev/github.com/google/go-github/v71/github) ([BSD-3-Clause](https://github.com/google/go-github/blob/v71.0.0/LICENSE))
- - [github.com/google/go-github/v73/github](https://pkg.go.dev/github.com/google/go-github/v73/github) ([BSD-3-Clause](https://github.com/google/go-github/blob/v73.0.0/LICENSE))
+ - [github.com/google/go-github/v74/github](https://pkg.go.dev/github.com/google/go-github/v74/github) ([BSD-3-Clause](https://github.com/google/go-github/blob/v74.0.0/LICENSE))
- [github.com/google/go-querystring/query](https://pkg.go.dev/github.com/google/go-querystring/query) ([BSD-3-Clause](https://github.com/google/go-querystring/blob/v1.1.0/LICENSE))
- [github.com/google/uuid](https://pkg.go.dev/github.com/google/uuid) ([BSD-3-Clause](https://github.com/google/uuid/blob/v1.6.0/LICENSE))
- [github.com/gorilla/mux](https://pkg.go.dev/github.com/gorilla/mux) ([BSD-3-Clause](https://github.com/gorilla/mux/blob/v1.8.0/LICENSE))
+ - [github.com/invopop/jsonschema](https://pkg.go.dev/github.com/invopop/jsonschema) ([MIT](https://github.com/invopop/jsonschema/blob/v0.13.0/COPYING))
- [github.com/josephburnett/jd/v2](https://pkg.go.dev/github.com/josephburnett/jd/v2) ([MIT](https://github.com/josephburnett/jd/blob/v1.9.2/LICENSE))
- [github.com/josharian/intern](https://pkg.go.dev/github.com/josharian/intern) ([MIT](https://github.com/josharian/intern/blob/v1.0.0/license.md))
- [github.com/mailru/easyjson](https://pkg.go.dev/github.com/mailru/easyjson) ([MIT](https://github.com/mailru/easyjson/blob/v0.7.7/LICENSE))
- - [github.com/mark3labs/mcp-go](https://pkg.go.dev/github.com/mark3labs/mcp-go) ([MIT](https://github.com/mark3labs/mcp-go/blob/v0.32.0/LICENSE))
+ - [github.com/mark3labs/mcp-go](https://pkg.go.dev/github.com/mark3labs/mcp-go) ([MIT](https://github.com/mark3labs/mcp-go/blob/v0.36.0/LICENSE))
- [github.com/migueleliasweb/go-github-mock/src/mock](https://pkg.go.dev/github.com/migueleliasweb/go-github-mock/src/mock) ([MIT](https://github.com/migueleliasweb/go-github-mock/blob/v1.3.0/LICENSE))
- [github.com/pelletier/go-toml/v2](https://pkg.go.dev/github.com/pelletier/go-toml/v2) ([MIT](https://github.com/pelletier/go-toml/blob/v2.2.3/LICENSE))
- [github.com/sagikazarmark/locafero](https://pkg.go.dev/github.com/sagikazarmark/locafero) ([MIT](https://github.com/sagikazarmark/locafero/blob/v0.9.0/LICENSE))
- [github.com/shurcooL/githubv4](https://pkg.go.dev/github.com/shurcooL/githubv4) ([MIT](https://github.com/shurcooL/githubv4/blob/48295856cce7/LICENSE))
- [github.com/shurcooL/graphql](https://pkg.go.dev/github.com/shurcooL/graphql) ([MIT](https://github.com/shurcooL/graphql/blob/ed46e5a46466/LICENSE))
- - [github.com/sirupsen/logrus](https://pkg.go.dev/github.com/sirupsen/logrus) ([MIT](https://github.com/sirupsen/logrus/blob/v1.9.3/LICENSE))
- [github.com/sourcegraph/conc](https://pkg.go.dev/github.com/sourcegraph/conc) ([MIT](https://github.com/sourcegraph/conc/blob/v0.3.0/LICENSE))
- [github.com/spf13/afero](https://pkg.go.dev/github.com/spf13/afero) ([Apache-2.0](https://github.com/spf13/afero/blob/v1.14.0/LICENSE.txt))
- [github.com/spf13/cast](https://pkg.go.dev/github.com/spf13/cast) ([MIT](https://github.com/spf13/cast/blob/v1.7.1/LICENSE))
@@ -34,6 +36,7 @@ Some packages may only be included on certain architectures or operating systems
- [github.com/spf13/pflag](https://pkg.go.dev/github.com/spf13/pflag) ([BSD-3-Clause](https://github.com/spf13/pflag/blob/v1.0.6/LICENSE))
- [github.com/spf13/viper](https://pkg.go.dev/github.com/spf13/viper) ([MIT](https://github.com/spf13/viper/blob/v1.20.1/LICENSE))
- [github.com/subosito/gotenv](https://pkg.go.dev/github.com/subosito/gotenv) ([MIT](https://github.com/subosito/gotenv/blob/v1.6.0/LICENSE))
+ - [github.com/wk8/go-ordered-map/v2](https://pkg.go.dev/github.com/wk8/go-ordered-map/v2) ([Apache-2.0](https://github.com/wk8/go-ordered-map/blob/v2.1.8/LICENSE))
- [github.com/yosida95/uritemplate/v3](https://pkg.go.dev/github.com/yosida95/uritemplate/v3) ([BSD-3-Clause](https://github.com/yosida95/uritemplate/blob/v3.0.2/LICENSE))
- [github.com/yudai/golcs](https://pkg.go.dev/github.com/yudai/golcs) ([MIT](https://github.com/yudai/golcs/blob/ecda9a501e82/LICENSE))
- [golang.org/x/exp](https://pkg.go.dev/golang.org/x/exp) ([BSD-3-Clause](https://cs.opensource.google/go/x/exp/+/8a7402ab:LICENSE))
diff --git a/third-party-licenses.linux.md b/third-party-licenses.linux.md
index 6a9f895cb..c279f72ea 100644
--- a/third-party-licenses.linux.md
+++ b/third-party-licenses.linux.md
@@ -7,26 +7,28 @@ The following open source dependencies are used to build the [github/github-mcp-
Some packages may only be included on certain architectures or operating systems.
+ - [github.com/bahlo/generic-list-go](https://pkg.go.dev/github.com/bahlo/generic-list-go) ([BSD-3-Clause](https://github.com/bahlo/generic-list-go/blob/v0.2.0/LICENSE))
+ - [github.com/buger/jsonparser](https://pkg.go.dev/github.com/buger/jsonparser) ([MIT](https://github.com/buger/jsonparser/blob/v1.1.1/LICENSE))
- [github.com/fsnotify/fsnotify](https://pkg.go.dev/github.com/fsnotify/fsnotify) ([BSD-3-Clause](https://github.com/fsnotify/fsnotify/blob/v1.8.0/LICENSE))
- [github.com/github/github-mcp-server](https://pkg.go.dev/github.com/github/github-mcp-server) ([MIT](https://github.com/github/github-mcp-server/blob/HEAD/LICENSE))
- [github.com/go-openapi/jsonpointer](https://pkg.go.dev/github.com/go-openapi/jsonpointer) ([Apache-2.0](https://github.com/go-openapi/jsonpointer/blob/v0.19.5/LICENSE))
- [github.com/go-openapi/swag](https://pkg.go.dev/github.com/go-openapi/swag) ([Apache-2.0](https://github.com/go-openapi/swag/blob/v0.21.1/LICENSE))
- [github.com/go-viper/mapstructure/v2](https://pkg.go.dev/github.com/go-viper/mapstructure/v2) ([MIT](https://github.com/go-viper/mapstructure/blob/v2.3.0/LICENSE))
- [github.com/google/go-github/v71/github](https://pkg.go.dev/github.com/google/go-github/v71/github) ([BSD-3-Clause](https://github.com/google/go-github/blob/v71.0.0/LICENSE))
- - [github.com/google/go-github/v73/github](https://pkg.go.dev/github.com/google/go-github/v73/github) ([BSD-3-Clause](https://github.com/google/go-github/blob/v73.0.0/LICENSE))
+ - [github.com/google/go-github/v74/github](https://pkg.go.dev/github.com/google/go-github/v74/github) ([BSD-3-Clause](https://github.com/google/go-github/blob/v74.0.0/LICENSE))
- [github.com/google/go-querystring/query](https://pkg.go.dev/github.com/google/go-querystring/query) ([BSD-3-Clause](https://github.com/google/go-querystring/blob/v1.1.0/LICENSE))
- [github.com/google/uuid](https://pkg.go.dev/github.com/google/uuid) ([BSD-3-Clause](https://github.com/google/uuid/blob/v1.6.0/LICENSE))
- [github.com/gorilla/mux](https://pkg.go.dev/github.com/gorilla/mux) ([BSD-3-Clause](https://github.com/gorilla/mux/blob/v1.8.0/LICENSE))
+ - [github.com/invopop/jsonschema](https://pkg.go.dev/github.com/invopop/jsonschema) ([MIT](https://github.com/invopop/jsonschema/blob/v0.13.0/COPYING))
- [github.com/josephburnett/jd/v2](https://pkg.go.dev/github.com/josephburnett/jd/v2) ([MIT](https://github.com/josephburnett/jd/blob/v1.9.2/LICENSE))
- [github.com/josharian/intern](https://pkg.go.dev/github.com/josharian/intern) ([MIT](https://github.com/josharian/intern/blob/v1.0.0/license.md))
- [github.com/mailru/easyjson](https://pkg.go.dev/github.com/mailru/easyjson) ([MIT](https://github.com/mailru/easyjson/blob/v0.7.7/LICENSE))
- - [github.com/mark3labs/mcp-go](https://pkg.go.dev/github.com/mark3labs/mcp-go) ([MIT](https://github.com/mark3labs/mcp-go/blob/v0.32.0/LICENSE))
+ - [github.com/mark3labs/mcp-go](https://pkg.go.dev/github.com/mark3labs/mcp-go) ([MIT](https://github.com/mark3labs/mcp-go/blob/v0.36.0/LICENSE))
- [github.com/migueleliasweb/go-github-mock/src/mock](https://pkg.go.dev/github.com/migueleliasweb/go-github-mock/src/mock) ([MIT](https://github.com/migueleliasweb/go-github-mock/blob/v1.3.0/LICENSE))
- [github.com/pelletier/go-toml/v2](https://pkg.go.dev/github.com/pelletier/go-toml/v2) ([MIT](https://github.com/pelletier/go-toml/blob/v2.2.3/LICENSE))
- [github.com/sagikazarmark/locafero](https://pkg.go.dev/github.com/sagikazarmark/locafero) ([MIT](https://github.com/sagikazarmark/locafero/blob/v0.9.0/LICENSE))
- [github.com/shurcooL/githubv4](https://pkg.go.dev/github.com/shurcooL/githubv4) ([MIT](https://github.com/shurcooL/githubv4/blob/48295856cce7/LICENSE))
- [github.com/shurcooL/graphql](https://pkg.go.dev/github.com/shurcooL/graphql) ([MIT](https://github.com/shurcooL/graphql/blob/ed46e5a46466/LICENSE))
- - [github.com/sirupsen/logrus](https://pkg.go.dev/github.com/sirupsen/logrus) ([MIT](https://github.com/sirupsen/logrus/blob/v1.9.3/LICENSE))
- [github.com/sourcegraph/conc](https://pkg.go.dev/github.com/sourcegraph/conc) ([MIT](https://github.com/sourcegraph/conc/blob/v0.3.0/LICENSE))
- [github.com/spf13/afero](https://pkg.go.dev/github.com/spf13/afero) ([Apache-2.0](https://github.com/spf13/afero/blob/v1.14.0/LICENSE.txt))
- [github.com/spf13/cast](https://pkg.go.dev/github.com/spf13/cast) ([MIT](https://github.com/spf13/cast/blob/v1.7.1/LICENSE))
@@ -34,6 +36,7 @@ Some packages may only be included on certain architectures or operating systems
- [github.com/spf13/pflag](https://pkg.go.dev/github.com/spf13/pflag) ([BSD-3-Clause](https://github.com/spf13/pflag/blob/v1.0.6/LICENSE))
- [github.com/spf13/viper](https://pkg.go.dev/github.com/spf13/viper) ([MIT](https://github.com/spf13/viper/blob/v1.20.1/LICENSE))
- [github.com/subosito/gotenv](https://pkg.go.dev/github.com/subosito/gotenv) ([MIT](https://github.com/subosito/gotenv/blob/v1.6.0/LICENSE))
+ - [github.com/wk8/go-ordered-map/v2](https://pkg.go.dev/github.com/wk8/go-ordered-map/v2) ([Apache-2.0](https://github.com/wk8/go-ordered-map/blob/v2.1.8/LICENSE))
- [github.com/yosida95/uritemplate/v3](https://pkg.go.dev/github.com/yosida95/uritemplate/v3) ([BSD-3-Clause](https://github.com/yosida95/uritemplate/blob/v3.0.2/LICENSE))
- [github.com/yudai/golcs](https://pkg.go.dev/github.com/yudai/golcs) ([MIT](https://github.com/yudai/golcs/blob/ecda9a501e82/LICENSE))
- [golang.org/x/exp](https://pkg.go.dev/golang.org/x/exp) ([BSD-3-Clause](https://cs.opensource.google/go/x/exp/+/8a7402ab:LICENSE))
diff --git a/third-party-licenses.windows.md b/third-party-licenses.windows.md
index 505c2d83e..531031dd8 100644
--- a/third-party-licenses.windows.md
+++ b/third-party-licenses.windows.md
@@ -7,27 +7,29 @@ The following open source dependencies are used to build the [github/github-mcp-
Some packages may only be included on certain architectures or operating systems.
+ - [github.com/bahlo/generic-list-go](https://pkg.go.dev/github.com/bahlo/generic-list-go) ([BSD-3-Clause](https://github.com/bahlo/generic-list-go/blob/v0.2.0/LICENSE))
+ - [github.com/buger/jsonparser](https://pkg.go.dev/github.com/buger/jsonparser) ([MIT](https://github.com/buger/jsonparser/blob/v1.1.1/LICENSE))
- [github.com/fsnotify/fsnotify](https://pkg.go.dev/github.com/fsnotify/fsnotify) ([BSD-3-Clause](https://github.com/fsnotify/fsnotify/blob/v1.8.0/LICENSE))
- [github.com/github/github-mcp-server](https://pkg.go.dev/github.com/github/github-mcp-server) ([MIT](https://github.com/github/github-mcp-server/blob/HEAD/LICENSE))
- [github.com/go-openapi/jsonpointer](https://pkg.go.dev/github.com/go-openapi/jsonpointer) ([Apache-2.0](https://github.com/go-openapi/jsonpointer/blob/v0.19.5/LICENSE))
- [github.com/go-openapi/swag](https://pkg.go.dev/github.com/go-openapi/swag) ([Apache-2.0](https://github.com/go-openapi/swag/blob/v0.21.1/LICENSE))
- [github.com/go-viper/mapstructure/v2](https://pkg.go.dev/github.com/go-viper/mapstructure/v2) ([MIT](https://github.com/go-viper/mapstructure/blob/v2.3.0/LICENSE))
- [github.com/google/go-github/v71/github](https://pkg.go.dev/github.com/google/go-github/v71/github) ([BSD-3-Clause](https://github.com/google/go-github/blob/v71.0.0/LICENSE))
- - [github.com/google/go-github/v73/github](https://pkg.go.dev/github.com/google/go-github/v73/github) ([BSD-3-Clause](https://github.com/google/go-github/blob/v73.0.0/LICENSE))
+ - [github.com/google/go-github/v74/github](https://pkg.go.dev/github.com/google/go-github/v74/github) ([BSD-3-Clause](https://github.com/google/go-github/blob/v74.0.0/LICENSE))
- [github.com/google/go-querystring/query](https://pkg.go.dev/github.com/google/go-querystring/query) ([BSD-3-Clause](https://github.com/google/go-querystring/blob/v1.1.0/LICENSE))
- [github.com/google/uuid](https://pkg.go.dev/github.com/google/uuid) ([BSD-3-Clause](https://github.com/google/uuid/blob/v1.6.0/LICENSE))
- [github.com/gorilla/mux](https://pkg.go.dev/github.com/gorilla/mux) ([BSD-3-Clause](https://github.com/gorilla/mux/blob/v1.8.0/LICENSE))
- [github.com/inconshreveable/mousetrap](https://pkg.go.dev/github.com/inconshreveable/mousetrap) ([Apache-2.0](https://github.com/inconshreveable/mousetrap/blob/v1.1.0/LICENSE))
+ - [github.com/invopop/jsonschema](https://pkg.go.dev/github.com/invopop/jsonschema) ([MIT](https://github.com/invopop/jsonschema/blob/v0.13.0/COPYING))
- [github.com/josephburnett/jd/v2](https://pkg.go.dev/github.com/josephburnett/jd/v2) ([MIT](https://github.com/josephburnett/jd/blob/v1.9.2/LICENSE))
- [github.com/josharian/intern](https://pkg.go.dev/github.com/josharian/intern) ([MIT](https://github.com/josharian/intern/blob/v1.0.0/license.md))
- [github.com/mailru/easyjson](https://pkg.go.dev/github.com/mailru/easyjson) ([MIT](https://github.com/mailru/easyjson/blob/v0.7.7/LICENSE))
- - [github.com/mark3labs/mcp-go](https://pkg.go.dev/github.com/mark3labs/mcp-go) ([MIT](https://github.com/mark3labs/mcp-go/blob/v0.32.0/LICENSE))
+ - [github.com/mark3labs/mcp-go](https://pkg.go.dev/github.com/mark3labs/mcp-go) ([MIT](https://github.com/mark3labs/mcp-go/blob/v0.36.0/LICENSE))
- [github.com/migueleliasweb/go-github-mock/src/mock](https://pkg.go.dev/github.com/migueleliasweb/go-github-mock/src/mock) ([MIT](https://github.com/migueleliasweb/go-github-mock/blob/v1.3.0/LICENSE))
- [github.com/pelletier/go-toml/v2](https://pkg.go.dev/github.com/pelletier/go-toml/v2) ([MIT](https://github.com/pelletier/go-toml/blob/v2.2.3/LICENSE))
- [github.com/sagikazarmark/locafero](https://pkg.go.dev/github.com/sagikazarmark/locafero) ([MIT](https://github.com/sagikazarmark/locafero/blob/v0.9.0/LICENSE))
- [github.com/shurcooL/githubv4](https://pkg.go.dev/github.com/shurcooL/githubv4) ([MIT](https://github.com/shurcooL/githubv4/blob/48295856cce7/LICENSE))
- [github.com/shurcooL/graphql](https://pkg.go.dev/github.com/shurcooL/graphql) ([MIT](https://github.com/shurcooL/graphql/blob/ed46e5a46466/LICENSE))
- - [github.com/sirupsen/logrus](https://pkg.go.dev/github.com/sirupsen/logrus) ([MIT](https://github.com/sirupsen/logrus/blob/v1.9.3/LICENSE))
- [github.com/sourcegraph/conc](https://pkg.go.dev/github.com/sourcegraph/conc) ([MIT](https://github.com/sourcegraph/conc/blob/v0.3.0/LICENSE))
- [github.com/spf13/afero](https://pkg.go.dev/github.com/spf13/afero) ([Apache-2.0](https://github.com/spf13/afero/blob/v1.14.0/LICENSE.txt))
- [github.com/spf13/cast](https://pkg.go.dev/github.com/spf13/cast) ([MIT](https://github.com/spf13/cast/blob/v1.7.1/LICENSE))
@@ -35,6 +37,7 @@ Some packages may only be included on certain architectures or operating systems
- [github.com/spf13/pflag](https://pkg.go.dev/github.com/spf13/pflag) ([BSD-3-Clause](https://github.com/spf13/pflag/blob/v1.0.6/LICENSE))
- [github.com/spf13/viper](https://pkg.go.dev/github.com/spf13/viper) ([MIT](https://github.com/spf13/viper/blob/v1.20.1/LICENSE))
- [github.com/subosito/gotenv](https://pkg.go.dev/github.com/subosito/gotenv) ([MIT](https://github.com/subosito/gotenv/blob/v1.6.0/LICENSE))
+ - [github.com/wk8/go-ordered-map/v2](https://pkg.go.dev/github.com/wk8/go-ordered-map/v2) ([Apache-2.0](https://github.com/wk8/go-ordered-map/blob/v2.1.8/LICENSE))
- [github.com/yosida95/uritemplate/v3](https://pkg.go.dev/github.com/yosida95/uritemplate/v3) ([BSD-3-Clause](https://github.com/yosida95/uritemplate/blob/v3.0.2/LICENSE))
- [github.com/yudai/golcs](https://pkg.go.dev/github.com/yudai/golcs) ([MIT](https://github.com/yudai/golcs/blob/ecda9a501e82/LICENSE))
- [golang.org/x/exp](https://pkg.go.dev/golang.org/x/exp) ([BSD-3-Clause](https://cs.opensource.google/go/x/exp/+/8a7402ab:LICENSE))
diff --git a/third-party/github.com/bahlo/generic-list-go/LICENSE b/third-party/github.com/bahlo/generic-list-go/LICENSE
new file mode 100644
index 000000000..6a66aea5e
--- /dev/null
+++ b/third-party/github.com/bahlo/generic-list-go/LICENSE
@@ -0,0 +1,27 @@
+Copyright (c) 2009 The Go Authors. All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are
+met:
+
+ * Redistributions of source code must retain the above copyright
+notice, this list of conditions and the following disclaimer.
+ * Redistributions in binary form must reproduce the above
+copyright notice, this list of conditions and the following disclaimer
+in the documentation and/or other materials provided with the
+distribution.
+ * Neither the name of Google Inc. nor the names of its
+contributors may be used to endorse or promote products derived from
+this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
diff --git a/third-party/github.com/sirupsen/logrus/LICENSE b/third-party/github.com/buger/jsonparser/LICENSE
similarity index 86%
rename from third-party/github.com/sirupsen/logrus/LICENSE
rename to third-party/github.com/buger/jsonparser/LICENSE
index f090cb42f..ac25aeb7d 100644
--- a/third-party/github.com/sirupsen/logrus/LICENSE
+++ b/third-party/github.com/buger/jsonparser/LICENSE
@@ -1,6 +1,6 @@
-The MIT License (MIT)
+MIT License
-Copyright (c) 2014 Simon Eskildsen
+Copyright (c) 2016 Leonid Bugaev
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
@@ -9,13 +9,13 @@ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
-The above copyright notice and this permission notice shall be included in
-all copies or substantial portions of the Software.
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
-THE SOFTWARE.
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/third-party/github.com/google/go-github/v73/github/LICENSE b/third-party/github.com/google/go-github/v74/github/LICENSE
similarity index 100%
rename from third-party/github.com/google/go-github/v73/github/LICENSE
rename to third-party/github.com/google/go-github/v74/github/LICENSE
diff --git a/third-party/github.com/invopop/jsonschema/COPYING b/third-party/github.com/invopop/jsonschema/COPYING
new file mode 100644
index 000000000..2993ec085
--- /dev/null
+++ b/third-party/github.com/invopop/jsonschema/COPYING
@@ -0,0 +1,19 @@
+Copyright (C) 2014 Alec Thomas
+
+Permission is hereby granted, free of charge, to any person obtaining a copy of
+this software and associated documentation files (the "Software"), to deal in
+the Software without restriction, including without limitation the rights to
+use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
+of the Software, and to permit persons to whom the Software is furnished to do
+so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/third-party/github.com/wk8/go-ordered-map/v2/LICENSE b/third-party/github.com/wk8/go-ordered-map/v2/LICENSE
new file mode 100644
index 000000000..8dada3eda
--- /dev/null
+++ b/third-party/github.com/wk8/go-ordered-map/v2/LICENSE
@@ -0,0 +1,201 @@
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
+
+ APPENDIX: How to apply the Apache License to your work.
+
+ To apply the Apache License to your work, attach the following
+ boilerplate notice, with the fields enclosed by brackets "{}"
+ replaced with your own identifying information. (Don't include
+ the brackets!) The text should be enclosed in the appropriate
+ comment syntax for the file format. We also recommend that a
+ file or class name and description of purpose be included on the
+ same "printed page" as the copyright notice for easier
+ identification within third-party archives.
+
+ Copyright {yyyy} {name of copyright owner}
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
] |