diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b4012f0b2..2307f6a28 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -10,6 +10,17 @@ Contributions to this project are [released](https://help.github.com/articles/gi Please note that this project is released with a [Contributor Code of Conduct](CODE_OF_CONDUCT.md). By participating in this project you agree to abide by its terms. +## What we're looking for + +We can't guarantee that every tool, feature, or pull request will be approved or merged. Our focus is on supporting high-quality, high-impact capabilities that advance agentic workflows and deliver clear value to developers. + +To increase the chances your request is accepted: +* Include real use cases or examples that demonstrate practical value +* 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) + +Thanks for contributing and for helping us build toolsets that are truly valuable! + ## Prerequisites for running and testing code These are one time installations required to be able to test your changes locally as part of the pull request (PR) submission process. diff --git a/README.md b/README.md index c5274ff83..be9288e40 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,16 @@ # GitHub MCP Server -The GitHub MCP Server is a [Model Context Protocol (MCP)](https://modelcontextprotocol.io/introduction) -server that provides seamless integration with GitHub APIs, enabling advanced -automation and interaction capabilities for developers and tools. +The GitHub MCP Server connects AI tools directly to GitHub's platform. This gives AI agents, assistants, and chatbots the ability to read repositories and code files, manage issues and PRs, analyze code, and automate workflows. All through natural language interactions. ### Use Cases -- Automating GitHub workflows and processes. -- Extracting and analyzing data from GitHub repositories. -- Building AI powered tools and applications that interact with GitHub's ecosystem. +- Repository Management: Browse and query code, search files, analyze commits, and understand project structure across any repository you have access to. +- Issue & PR Automation: Create, update, and manage issues and pull requests. Let AI help triage bugs, review code changes, and maintain project boards. +- CI/CD & Workflow Intelligence: Monitor GitHub Actions workflow runs, analyze build failures, manage releases, and get insights into your development pipeline. +- Code Analysis: Examine security findings, review Dependabot alerts, understand code patterns, and get comprehensive insights into your codebase. +- Team Collaboration: Access discussions, manage notifications, analyze team activity, and streamline processes for your team. + +Built for developers who want to connect their AI tools to GitHub context and capabilities, from simple natural language queries to complex multi-step agent workflows. --- @@ -18,17 +20,15 @@ automation and interaction capabilities for developers and tools. The remote GitHub MCP Server is hosted by GitHub and provides the easiest method for getting up and running. If your MCP host does not support remote MCP servers, don't worry! You can use the [local version of the GitHub MCP Server](https://github.com/github/github-mcp-server?tab=readme-ov-file#local-github-mcp-server) instead. -## Prerequisites - -1. An MCP host that supports the latest MCP specification and remote servers, such as [VS Code](https://code.visualstudio.com/). +### Prerequisites -## Installation +1. A compatible MCP host with remote server support (VS Code 1.101+, Claude Desktop, Cursor, Windsurf, etc.) +2. Any applicable [policies enabled](https://github.com/github/github-mcp-server/blob/main/docs/policies-and-governance.md) -### Usage with VS Code +### Install in VS Code For quick installation, use one of the one-click install buttons above. Once you complete that flow, toggle Agent mode (located by the Copilot Chat text input) and the server will start. Make sure you're using [VS Code 1.101](https://code.visualstudio.com/updates/v1_101) or [later](https://code.visualstudio.com/updates) for remote MCP and OAuth support. - Alternatively, to manually configure VS Code, choose the appropriate JSON block from the examples below and add it to your host configuration: @@ -77,48 +77,21 @@ Alternatively, to manually configure VS Code, choose the appropriate JSON block
-### Usage in other MCP Hosts +### Install in other MCP hosts +- **[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 Applications](/docs/installation-guides/install-claude.md)** - Installation guide for Claude Web, Claude Desktop and Claude Code CLI +- **[Cursor](/docs/installation-guides/install-cursor.md)** - Installation guide for Cursor IDE +- **[Windsurf](/docs/installation-guides/install-windsurf.md)** - Installation guide for Windsurf IDE -For MCP Hosts that are [Remote MCP-compatible](docs/host-integration.md), choose the appropriate JSON block from the examples below and add it to your host configuration: +> **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. - - - - - - -
Using OAuthUsing a GitHub PAT
- -```json -{ - "mcpServers": { - "github": { - "url": "https://api.githubcopilot.com/mcp/" - } - } -} -``` - - - -```json -{ - "mcpServers": { - "github": { - "url": "https://api.githubcopilot.com/mcp/", - "authorization_token": "Bearer " - } - } -} -``` - -
- -> **Note:** The exact configuration format may vary by host. Refer to your host's documentation for the correct syntax and location for remote MCP server setup. +> ⚠️ **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. +See [Remote Server Documentation](/docs/remote-server.md) on how to pass additional configuration settings to the remote GitHub MCP Server. --- @@ -126,22 +99,72 @@ See [Remote Server Documentation](docs/remote-server.md) on how to pass addition [![Install with Docker in VS Code](https://img.shields.io/badge/VS_Code-Install_Server-0098FF?style=flat-square&logo=visualstudiocode&logoColor=white)](https://insiders.vscode.dev/redirect/mcp/install?name=github&inputs=%5B%7B%22id%22%3A%22github_token%22%2C%22type%22%3A%22promptString%22%2C%22description%22%3A%22GitHub%20Personal%20Access%20Token%22%2C%22password%22%3Atrue%7D%5D&config=%7B%22command%22%3A%22docker%22%2C%22args%22%3A%5B%22run%22%2C%22-i%22%2C%22--rm%22%2C%22-e%22%2C%22GITHUB_PERSONAL_ACCESS_TOKEN%22%2C%22ghcr.io%2Fgithub%2Fgithub-mcp-server%22%5D%2C%22env%22%3A%7B%22GITHUB_PERSONAL_ACCESS_TOKEN%22%3A%22%24%7Binput%3Agithub_token%7D%22%7D%7D) [![Install with Docker in VS Code Insiders](https://img.shields.io/badge/VS_Code_Insiders-Install_Server-24bfa5?style=flat-square&logo=visualstudiocode&logoColor=white)](https://insiders.vscode.dev/redirect/mcp/install?name=github&inputs=%5B%7B%22id%22%3A%22github_token%22%2C%22type%22%3A%22promptString%22%2C%22description%22%3A%22GitHub%20Personal%20Access%20Token%22%2C%22password%22%3Atrue%7D%5D&config=%7B%22command%22%3A%22docker%22%2C%22args%22%3A%5B%22run%22%2C%22-i%22%2C%22--rm%22%2C%22-e%22%2C%22GITHUB_PERSONAL_ACCESS_TOKEN%22%2C%22ghcr.io%2Fgithub%2Fgithub-mcp-server%22%5D%2C%22env%22%3A%7B%22GITHUB_PERSONAL_ACCESS_TOKEN%22%3A%22%24%7Binput%3Agithub_token%7D%22%7D%7D&quality=insiders) -## Prerequisites +### Prerequisites 1. To run the server in a container, you will need to have [Docker](https://www.docker.com/) installed. 2. Once Docker is installed, you will also need to ensure Docker is running. The image is public; if you get errors on pull, you may have an expired token and need to `docker logout ghcr.io`. 3. Lastly you will need to [Create a GitHub Personal Access Token](https://github.com/settings/personal-access-tokens/new). The MCP server can use many of the GitHub APIs, so enable the permissions that you feel comfortable granting your AI tools (to learn more about access tokens, please check out the [documentation](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens)). +
Handling PATs Securely + +### Environment Variables (Recommended) +To keep your GitHub PAT secure and reusable across different MCP hosts: + +1. **Store your PAT in environment variables** + ```bash + export GITHUB_PAT=your_token_here + ``` + Or create a `.env` file: + ```env + GITHUB_PAT=your_token_here + ``` + +2. **Protect your `.env` file** + ```bash + # Add to .gitignore to prevent accidental commits + echo ".env" >> .gitignore + ``` + +3. **Reference the token in configurations** + ```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" + } + ``` + +> **Note**: Environment variable support varies by host app and IDE. Some applications (like Windsurf) require hardcoded tokens in config files. + +### Token Security Best Practices + +- **Minimum scopes**: Only grant necessary permissions + - `repo` - Repository operations + - `read:packages` - Docker image access +- **Separate tokens**: Use different PATs for different projects/environments +- **Regular rotation**: Update tokens periodically +- **Never commit**: Keep tokens out of version control +- **File permissions**: Restrict access to config files containing tokens + ```bash + chmod 600 ~/.your-app/config.json + ``` + +
+ ## Installation -### Usage with VS Code +### Install in GitHub Copilot on VS Code -For quick installation, use one of the one-click install buttons. Once you complete that flow, toggle Agent mode (located by the Copilot Chat text input) and the server will start. +For quick installation, use one of the one-click install buttons above. Once you complete that flow, toggle Agent mode (located by the Copilot Chat text input) and the server will start. -### Usage in other MCP Hosts +More about using MCP server tools in VS Code's [agent mode documentation](https://code.visualstudio.com/docs/copilot/chat/mcp-servers). + +Install in GitHub Copilot on other IDEs (JetBrains, Visual Studio, Eclipse, etc.) -Add the following JSON block to your IDE MCP settings. +Add the following JSON block to your IDE's MCP settings. ```json { @@ -174,8 +197,11 @@ Add the following JSON block to your IDE MCP settings. } ``` -Optionally, you can add a similar example (i.e. without the mcp key) to a file called `.vscode/mcp.json` in your workspace. This will allow you to share the configuration with others. +Optionally, you can add a similar example (i.e. without the mcp key) to a file called `.vscode/mcp.json` in your workspace. This will allow you to share the configuration with other host applications that accept the same format. +
+Example JSON block without the MCP key included +
```json { @@ -204,33 +230,22 @@ Optionally, you can add a similar example (i.e. without the mcp key) to a file c } } } - ``` -More about using MCP server tools in VS Code's [agent mode documentation](https://code.visualstudio.com/docs/copilot/chat/mcp-servers). +
-### Usage with Claude Desktop +### Install in Other MCP Hosts -```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": "" - } - } - } -} -``` +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 +- **[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)**. + +> **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. ### Build from source @@ -281,153 +296,6 @@ The following sets of tools are available (all are on by default): | `users` | GitHub User related tools | -#### Specifying Toolsets - -To specify toolsets you want available to the LLM, you can pass an allow-list in two ways: - -1. **Using Command Line Argument**: - - ```bash - github-mcp-server --toolsets repos,issues,pull_requests,actions,code_security - ``` - -2. **Using Environment Variable**: - ```bash - GITHUB_TOOLSETS="repos,issues,pull_requests,actions,code_security" ./github-mcp-server - ``` - -The environment variable `GITHUB_TOOLSETS` takes precedence over the command line argument if both are provided. - -### Using Toolsets With Docker - -When using Docker, you can pass the toolsets as environment variables: - -```bash -docker run -i --rm \ - -e GITHUB_PERSONAL_ACCESS_TOKEN= \ - -e GITHUB_TOOLSETS="repos,issues,pull_requests,actions,code_security,experiments" \ - ghcr.io/github/github-mcp-server -``` - -### The "all" Toolset - -The special toolset `all` can be provided to enable all available toolsets regardless of any other configuration: - -```bash -./github-mcp-server --toolsets all -``` - -Or using the environment variable: - -```bash -GITHUB_TOOLSETS="all" ./github-mcp-server -``` - -## Dynamic Tool Discovery - -**Note**: This feature is currently in beta and may not be available in all environments. Please test it out and let us know if you encounter any issues. - -Instead of starting with all tools enabled, you can turn on dynamic toolset discovery. Dynamic toolsets allow the MCP host to list and enable toolsets in response to a user prompt. This should help to avoid situations where the model gets confused by the sheer number of tools available. - -### Using Dynamic Tool Discovery - -When using the binary, you can pass the `--dynamic-toolsets` flag. - -```bash -./github-mcp-server --dynamic-toolsets -``` - -When using Docker, you can pass the toolsets as environment variables: - -```bash -docker run -i --rm \ - -e GITHUB_PERSONAL_ACCESS_TOKEN= \ - -e GITHUB_DYNAMIC_TOOLSETS=1 \ - ghcr.io/github/github-mcp-server -``` - -## Read-Only Mode - -To run the server in read-only mode, you can use the `--read-only` flag. This will only offer read-only tools, preventing any modifications to repositories, issues, pull requests, etc. - -```bash -./github-mcp-server --read-only -``` - -When using Docker, you can pass the read-only mode as an environment variable: - -```bash -docker run -i --rm \ - -e GITHUB_PERSONAL_ACCESS_TOKEN= \ - -e GITHUB_READ_ONLY=1 \ - ghcr.io/github/github-mcp-server -``` - -## GitHub Enterprise Server and Enterprise Cloud with data residency (ghe.com) - -The flag `--gh-host` and the environment variable `GITHUB_HOST` can be used to set -the hostname for GitHub Enterprise Server or GitHub Enterprise Cloud with data residency. - -- For GitHub Enterprise Server, prefix the hostname with the `https://` URI scheme, as it otherwise defaults to `http://`, which GitHub Enterprise Server does not support. -- For GitHub Enterprise Cloud with data residency, use `https://YOURSUBDOMAIN.ghe.com` as the hostname. -``` json -"github": { - "command": "docker", - "args": [ - "run", - "-i", - "--rm", - "-e", - "GITHUB_PERSONAL_ACCESS_TOKEN", - "-e", - "GITHUB_HOST", - "ghcr.io/github/github-mcp-server" - ], - "env": { - "GITHUB_PERSONAL_ACCESS_TOKEN": "${input:github_token}", - "GITHUB_HOST": "https://" - } -} -``` - -## i18n / Overriding Descriptions - -The descriptions of the tools can be overridden by creating a -`github-mcp-server-config.json` file in the same directory as the binary. - -The file should contain a JSON object with the tool names as keys and the new -descriptions as values. For example: - -```json -{ - "TOOL_ADD_ISSUE_COMMENT_DESCRIPTION": "an alternative description", - "TOOL_CREATE_BRANCH_DESCRIPTION": "Create a new branch in a GitHub repository" -} -``` - -You can create an export of the current translations by running the binary with -the `--export-translations` flag. - -This flag will preserve any translations/overrides you have made, while adding -any new translations that have been added to the binary since the last time you -exported. - -```sh -./github-mcp-server --export-translations -cat github-mcp-server-config.json -``` - -You can also use ENV vars to override the descriptions. The environment -variable names are the same as the keys in the JSON file, prefixed with -`GITHUB_MCP_` and all uppercase. - -For example, to override the `TOOL_ADD_ISSUE_COMMENT_DESCRIPTION` tool, you can -set the following environment variable: - -```sh -export GITHUB_MCP_TOOL_ADD_ISSUE_COMMENT_DESCRIPTION="an alternative description" -``` - ## Tools @@ -581,21 +449,23 @@ export GITHUB_MCP_TOOL_ADD_ISSUE_COMMENT_DESCRIPTION="an alternative description - `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) - **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) + - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) - `repo`: Repository name (string, required) @@ -610,6 +480,13 @@ export GITHUB_MCP_TOOL_ADD_ISSUE_COMMENT_DESCRIPTION="an alternative description - `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) @@ -647,6 +524,27 @@ export GITHUB_MCP_TOOL_ADD_ISSUE_COMMENT_DESCRIPTION="an alternative description - `sort`: Sort order (string, optional) - `state`: Filter by state (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) @@ -722,7 +620,7 @@ export GITHUB_MCP_TOOL_ADD_ISSUE_COMMENT_DESCRIPTION="an alternative description Pull Requests -- **add_pull_request_review_comment_to_pending_review** - Add comment to the requester's latest pending pull request review +- **add_comment_to_pending_review** - Add review comment to the requester's latest pending pull request review - `body`: The text of the review comment (string, required) - `line`: The line of the blob in the pull request diff that the comment applies to. For multi-line comments, the last line of the range (number, optional) - `owner`: Repository owner (string, required) @@ -940,7 +838,7 @@ export GITHUB_MCP_TOOL_ADD_ISSUE_COMMENT_DESCRIPTION="an alternative description - `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) - - `q`: Search query using GitHub code search syntax (string, required) + - `query`: Search query using GitHub code search syntax (string, required) - `sort`: Sort field ('indexed' only) (string, optional) - **search_repositories** - Search repositories @@ -997,6 +895,153 @@ export GITHUB_MCP_TOOL_ADD_ISSUE_COMMENT_DESCRIPTION="an alternative description +#### Specifying Toolsets + +To specify toolsets you want available to the LLM, you can pass an allow-list in two ways: + +1. **Using Command Line Argument**: + + ```bash + github-mcp-server --toolsets repos,issues,pull_requests,actions,code_security + ``` + +2. **Using Environment Variable**: + ```bash + GITHUB_TOOLSETS="repos,issues,pull_requests,actions,code_security" ./github-mcp-server + ``` + +The environment variable `GITHUB_TOOLSETS` takes precedence over the command line argument if both are provided. + +### Using Toolsets With Docker + +When using Docker, you can pass the toolsets as environment variables: + +```bash +docker run -i --rm \ + -e GITHUB_PERSONAL_ACCESS_TOKEN= \ + -e GITHUB_TOOLSETS="repos,issues,pull_requests,actions,code_security,experiments" \ + ghcr.io/github/github-mcp-server +``` + +### The "all" Toolset + +The special toolset `all` can be provided to enable all available toolsets regardless of any other configuration: + +```bash +./github-mcp-server --toolsets all +``` + +Or using the environment variable: + +```bash +GITHUB_TOOLSETS="all" ./github-mcp-server +``` + +## Dynamic Tool Discovery + +**Note**: This feature is currently in beta and may not be available in all environments. Please test it out and let us know if you encounter any issues. + +Instead of starting with all tools enabled, you can turn on dynamic toolset discovery. Dynamic toolsets allow the MCP host to list and enable toolsets in response to a user prompt. This should help to avoid situations where the model gets confused by the sheer number of tools available. + +### Using Dynamic Tool Discovery + +When using the binary, you can pass the `--dynamic-toolsets` flag. + +```bash +./github-mcp-server --dynamic-toolsets +``` + +When using Docker, you can pass the toolsets as environment variables: + +```bash +docker run -i --rm \ + -e GITHUB_PERSONAL_ACCESS_TOKEN= \ + -e GITHUB_DYNAMIC_TOOLSETS=1 \ + ghcr.io/github/github-mcp-server +``` + +## Read-Only Mode + +To run the server in read-only mode, you can use the `--read-only` flag. This will only offer read-only tools, preventing any modifications to repositories, issues, pull requests, etc. + +```bash +./github-mcp-server --read-only +``` + +When using Docker, you can pass the read-only mode as an environment variable: + +```bash +docker run -i --rm \ + -e GITHUB_PERSONAL_ACCESS_TOKEN= \ + -e GITHUB_READ_ONLY=1 \ + ghcr.io/github/github-mcp-server +``` + +## GitHub Enterprise Server and Enterprise Cloud with data residency (ghe.com) + +The flag `--gh-host` and the environment variable `GITHUB_HOST` can be used to set +the hostname for GitHub Enterprise Server or GitHub Enterprise Cloud with data residency. + +- For GitHub Enterprise Server, prefix the hostname with the `https://` URI scheme, as it otherwise defaults to `http://`, which GitHub Enterprise Server does not support. +- For GitHub Enterprise Cloud with data residency, use `https://YOURSUBDOMAIN.ghe.com` as the hostname. +``` json +"github": { + "command": "docker", + "args": [ + "run", + "-i", + "--rm", + "-e", + "GITHUB_PERSONAL_ACCESS_TOKEN", + "-e", + "GITHUB_HOST", + "ghcr.io/github/github-mcp-server" + ], + "env": { + "GITHUB_PERSONAL_ACCESS_TOKEN": "${input:github_token}", + "GITHUB_HOST": "https://" + } +} +``` + +## i18n / Overriding Descriptions + +The descriptions of the tools can be overridden by creating a +`github-mcp-server-config.json` file in the same directory as the binary. + +The file should contain a JSON object with the tool names as keys and the new +descriptions as values. For example: + +```json +{ + "TOOL_ADD_ISSUE_COMMENT_DESCRIPTION": "an alternative description", + "TOOL_CREATE_BRANCH_DESCRIPTION": "Create a new branch in a GitHub repository" +} +``` + +You can create an export of the current translations by running the binary with +the `--export-translations` flag. + +This flag will preserve any translations/overrides you have made, while adding +any new translations that have been added to the binary since the last time you +exported. + +```sh +./github-mcp-server --export-translations +cat github-mcp-server-config.json +``` + +You can also use ENV vars to override the descriptions. The environment +variable names are the same as the keys in the JSON file, prefixed with +`GITHUB_MCP_` and all uppercase. + +For example, to override the `TOOL_ADD_ISSUE_COMMENT_DESCRIPTION` tool, you can +set the following environment variable: + +```sh +export GITHUB_MCP_TOOL_ADD_ISSUE_COMMENT_DESCRIPTION="an alternative description" +``` + ## Library Usage The exported Go API of this module should currently be considered unstable, and subject to breaking changes. In the future, we may offer stability; please file an issue if there is a use case where this would be valuable. diff --git a/cmd/github-mcp-server/generate_docs.go b/cmd/github-mcp-server/generate_docs.go index dfd66d288..983ed4398 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/v72/github" + gogithub "github.com/google/go-github/v73/github" "github.com/mark3labs/mcp-go/mcp" "github.com/shurcooL/githubv4" "github.com/spf13/cobra" 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 new file mode 100644 index 000000000..f55cc6bef --- /dev/null +++ b/docs/installation-guides/README.md @@ -0,0 +1,95 @@ +# GitHub MCP Server Installation Guides + +This directory contains detailed installation instructions for the GitHub MCP Server across different host applications and IDEs. Choose the guide that matches your development environment. + +## Installation Guides by Host Application +- **[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 +- **[Windsurf](install-windsurf.md)** - Installation guide for Windsurf IDE + +## Support by Host Application + +| Host Application | Local GitHub MCP Support | Remote GitHub MCP Support | Prerequisites | Difficulty | +|-----------------|---------------|----------------|---------------|------------| +| Copilot in VS Code | ✅ | ✅ Full (OAuth + PAT) | Local: Docker or Go build, GitHub PAT
Remote: VS Code 1.101+ | Easy | +| Copilot Coding Agent | ✅ | ✅ Full (on by default; no auth needed) | Any _paid_ copilot license | Default on | +| Copilot in Visual Studio | ✅ | ✅ PAT + ❌ No OAuth | Local: Docker or Go build, GitHub PAT
Remote: Visual Studio 17.14+ | Easy | +| Copilot in JetBrains | ✅ | ✅ PAT + ❌ No OAuth | Local: Docker or Go build, GitHub PAT
Remote: JetBrains Copilot Extension v1.5.35+ | Easy | +| 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 | +| 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 | + +**Legend:** +- ✅ = Fully supported +- ❌ = Not yet supported + +**Note:** Remote MCP support requires host applications to register a GitHub App or OAuth app for OAuth flow support – even if the new OAuth spec is supported by that host app. Currently, only VS Code has full remote GitHub server support. + +## Installation Methods + +The GitHub MCP Server can be installed using several methods. **Docker is the most popular and recommended approach** for most users, but alternatives are available depending on your needs: + +### 🐳 Docker (Most Common & Recommended) +- **Pros**: No local build required, consistent environment, easy updates, works across all platforms +- **Cons**: Requires Docker installed and running +- **Best for**: Most users, especially those already using Docker or wanting the simplest setup +- **Used by**: Claude Desktop, Copilot in VS Code, Cursor, Windsurf, etc. + +### 📦 Pre-built Binary (Lightweight Alternative) +- **Pros**: No Docker required, direct execution via stdio, minimal setup +- **Cons**: Need to manually download and manage updates, platform-specific binaries +- **Best for**: Minimal environments, users who prefer not to use Docker +- **Used by**: Claude Code CLI, lightweight setups + +### 🔨 Build from Source (Advanced Users) +- **Pros**: Latest features, full customization, no external dependencies +- **Cons**: Requires Go development environment, more complex setup +- **Prerequisites**: [Go 1.24+](https://go.dev/doc/install) +- **Build command**: `go build -o github-mcp-server cmd/github-mcp-server/main.go` +- **Best for**: Developers who want the latest features or need custom modifications + +### Important Notes on the GitHub MCP Server + +- **Docker Image**: The official Docker image is now `ghcr.io/github/github-mcp-server` +- **npm Package**: The npm package @modelcontextprotocol/server-github is no longer supported as of April 2025 +- **Remote Server**: The remote server URL is `https://api.githubcopilot.com/mcp/` + +## General Prerequisites + +All installations with Personal Access Tokens (PAT) require: +- **GitHub Personal Access Token (PAT)**: [Create one here](https://github.com/settings/personal-access-tokens/new) + +Optional (depending on installation method): +- **Docker** (for Docker-based installations): [Download Docker](https://www.docker.com/) +- **Go 1.24+** (for building from source): [Install Go](https://go.dev/doc/install) + +## Security Best Practices + +Regardless of which installation method you choose, follow these security guidelines: + +1. **Secure Token Storage**: Never commit your GitHub PAT to version control +2. **Limit Token Scope**: Only grant necessary permissions to your GitHub PAT +3. **File Permissions**: Restrict access to configuration files containing tokens +4. **Regular Rotation**: Periodically rotate your GitHub Personal Access Tokens +5. **Environment Variables**: Use environment variables when supported by your host + +## Getting Help + +If you encounter issues: +1. Check the troubleshooting section in your specific installation guide +2. Verify your GitHub PAT has the required permissions +3. Ensure Docker is running (for local installations) +4. Review your host application's logs for error messages +5. Consult the main [README.md](README.md) for additional configuration options + +## Configuration Options + +After installation, you may want to explore: +- **Toolsets**: Enable/disable specific GitHub API capabilities +- **Read-Only Mode**: Restrict to read-only operations +- **Dynamic Tool Discovery**: Enable tools on-demand + diff --git a/docs/installation-guides/install-claude.md b/docs/installation-guides/install-claude.md new file mode 100644 index 000000000..2c50be2f9 --- /dev/null +++ b/docs/installation-guides/install-claude.md @@ -0,0 +1,204 @@ +# 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. + +### Prerequisites + +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 + +**Alternative**: Use Claude Desktop or Claude Code CLI for reliable GitHub MCP Server integration. + +--- + +## Claude Code CLI + +Claude Code CLI provides command-line access to Claude with MCP server integration. + +### Prerequisites + +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 + +### Installation + +Run the following command to add the GitHub MCP server using Docker: + +```bash +claude mcp add github -- docker run -i --rm -e GITHUB_PERSONAL_ACCESS_TOKEN ghcr.io/github/github-mcp-server +``` + +Then set the environment variable: +```bash +claude mcp update github -e GITHUB_PERSONAL_ACCESS_TOKEN=your_github_pat +``` + +Or as a single command with the token inline: +```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"}}' +``` + +**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. + +### Configuration Options + +- 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) + +### Verification + +Run the following command to verify the installation: +```bash +claude mcp list +``` + +--- + +## Claude Desktop + +Claude Desktop provides a graphical interface for interacting with the GitHub MCP Server. + +### Prerequisites + +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 + +### 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" + } + } + } +} +``` + +**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. + +### Using Environment Variables + +Claude Desktop supports environment variable references. You can use: + +```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" + } + } + } +} +``` + +Then set the environment variable in your system before starting Claude Desktop. + +### Installation 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 + +--- + +## 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 + +--- + +## Additional Resources + +- [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) diff --git a/docs/installation-guides/install-cursor.md b/docs/installation-guides/install-cursor.md new file mode 100644 index 000000000..b069addd3 --- /dev/null +++ b/docs/installation-guides/install-cursor.md @@ -0,0 +1,107 @@ +# 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) + +[![Install MCP Server](https://cursor.com/deeplink/mcp-install-dark.svg)](https://cursor.com/install-mcp?name=github&config=eyJ1cmwiOiJodHRwczovL2FwaS5naXRodWJjb3BpbG90LmNvbS9tY3AvIiwiaGVhZGVycyI6eyJBdXRob3JpemF0aW9uIjoiQmVhcmVyIFlPVVJfR0lUSFVCX1BBVCJ9LCJ0eXBlIjoiaHR0cCJ9) + +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) +4. Save the file +5. Restart Cursor + +### Streamable HTTP Configuration + +```json +{ + "mcpServers": { + "github": { + "url": "https://api.githubcopilot.com/mcp/", + "headers": { + "Authorization": "Bearer YOUR_GITHUB_PAT" + } + } + } +} +``` + +## Local Server Setup + +[![Install MCP Server](https://cursor.com/deeplink/mcp-install-dark.svg)](https://cursor.com/install-mcp?name=github&config=eyJjb21tYW5kIjoiZG9ja2VyIiwiYXJncyI6WyJydW4iLCItaSIsIi0tcm0iLCItZSIsIkdJVEhVQl9QRVJTT05BTF9BQ0NFU1NfVE9LRU4iLCJnaGNyLmlvL2dpdGh1Yi9naXRodWItbWNwLXNlcnZlciJdLCJlbnYiOnsiR0lUSFVCX1BFUlNPTkFMX0FDQ0VTU19UT0tFTiI6IllPVVJfR0lUSFVCX1BHVCJ9fQ==) + +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) +4. Save the file +5. Restart Cursor + +### Docker Configuration + +```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" + } + } + } +} +``` + +> **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. + +## Configuration Files + +- **Global (all projects)**: `~/.cursor/mcp.json` +- **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" +4. Test with: "List my GitHub repositories" + +## 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 +- **Check logs**: Look for MCP-related errors in Cursor logs + +## Important Notes + +- **Docker image**: `ghcr.io/github/github-mcp-server` (official and supported) +- **npm package**: `@modelcontextprotocol/server-github` (deprecated as of April 2025 - no longer functional) +- **Cursor specifics**: Supports both project and global configurations, uses `mcpServers` key diff --git a/docs/installation-guides/install-other-copilot-ides.md b/docs/installation-guides/install-other-copilot-ides.md new file mode 100644 index 000000000..38b48bbbd --- /dev/null +++ b/docs/installation-guides/install-other-copilot-ides.md @@ -0,0 +1,265 @@ +# Install GitHub MCP Server in Copilot IDEs + +Quick setup guide for the GitHub MCP server in GitHub Copilot across different IDEs. For VS Code instructions, refer to the [VS Code install guide in the README](/README.md#installation-in-vs-code) + +### Requirements: +- **GitHub Copilot License**: Any Copilot plan (Free, Pro, Pro+, Business, Enterprise) for Copilot access +- **GitHub Account**: Individual GitHub account (organization/enterprise membership optional) for GitHub MCP server access +- **MCP Servers in Copilot Policy**: Organizations assigning Copilot seats must enable this policy for all MCP access in Copilot for VS Code and Copilot Coding Agent – all other Copilot IDEs will migrate to this policy in the coming months +- **Editor Preview Policy**: Organizations assigning Copilot seats must enable this policy for OAuth access while the Remote GitHub MCP Server is in public preview + +> **Note:** All Copilot IDEs now support the remote GitHub MCP server. VS Code offers OAuth authentication, while Visual Studio, JetBrains IDEs, Xcode, and Eclipse currently use PAT authentication with OAuth support coming soon. + +## Visual Studio + +Requires Visual Studio 2022 version 17.14 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** +2. Add this configuration: +```json +{ + "servers": { + "github": { + "url": "https://api.githubcopilot.com/mcp/", + "authorization_token": "Bearer YOUR_GITHUB_PAT" + } + } +} +``` +3. Restart Visual Studio + +### 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 +2. Add this configuration: +```json +{ + "inputs": [ + { + "id": "github_pat", + "description": "GitHub personal access token", + "type": "promptString", + "password": true + } + ], + "servers": { + "github": { + "type": "stdio", + "command": "docker", + "args": [ + "run", "-i", "--rm", "-e", "GITHUB_PERSONAL_ACCESS_TOKEN", + "ghcr.io/github/github-mcp-server" + ], + "env": { + "GITHUB_PERSONAL_ACCESS_TOKEN": "${input:github_pat}" + } + } + } +} +``` +3. Save the file and restart Visual Studio + +**Documentation:** [Visual Studio MCP Guide](https://learn.microsoft.com/en-us/visualstudio/ide/mcp-servers?view=vs-2022) + +--- + +## JetBrains IDEs + +Agent mode and MCP support available in public preview across IntelliJ IDEA, PyCharm, WebStorm, and other JetBrains IDEs. + +### Remote Server (Recommended) + +The remote GitHub MCP server is hosted by GitHub and provides automatic updates with no local setup required. + +> **Note**: OAuth authentication for the remote GitHub server is not yet supported in JetBrains IDEs. You must use a Personal Access Token (PAT). + +#### Configuration Steps +1. Install/update the GitHub Copilot plugin +2. Click **GitHub Copilot icon in the status bar** → **Edit Settings** → **Model Context Protocol** → **Configure** +3. Add configuration: +```json +{ + "servers": { + "github": { + "url": "https://api.githubcopilot.com/mcp/", + "requestInit": { + "headers": { + "Authorization": "Bearer YOUR_GITHUB_PAT" + } + } + } + } +} +``` +4. Press `Ctrl + S` or `Command + S` to save, or close the `mcp.json` file. The configuration should take effect immediately and restart all the MCP servers defined. You can restart the IDE if needed. + +### Local Server + +For users who prefer to run the GitHub MCP server locally. Requires Docker installed and running. + +#### Configuration +```json +{ + "servers": { + "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" + } + } + } +} +``` + +**Documentation:** [JetBrains Copilot Guide](https://plugins.jetbrains.com/plugin/17718-github-copilot) + +--- + +## Xcode + +Agent mode and MCP support now available in public preview for Xcode. + +### Remote Server (Recommended) + +The remote GitHub MCP server is hosted by GitHub and provides automatic updates with no local setup required. + +> **Note**: OAuth authentication for the remote GitHub server is not yet supported in Xcode. You must use a Personal Access Token (PAT). + +#### Configuration Steps +1. Install/update [GitHub Copilot for Xcode](https://github.com/github/CopilotForXcode) +2. Open **GitHub Copilot for Xcode app** → **Agent Mode** → **🛠️ Tool Picker** → **Edit Config** +3. Configure your MCP servers: +```json +{ + "servers": { + "github": { + "url": "https://api.githubcopilot.com/mcp/", + "requestInit": { + "headers": { + "Authorization": "Bearer YOUR_GITHUB_PAT" + } + } + } + } +} +``` + +### Local Server + +For users who prefer to run the GitHub MCP server locally. Requires Docker installed and running. + +#### Configuration +```json +{ + "servers": { + "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" + } + } + } +} +``` + +**Documentation:** [Xcode Copilot Guide](https://devblogs.microsoft.com/xcode/github-copilot-exploring-agent-mode-and-mcp-support-in-public-preview-for-xcode/) + +--- + +## Eclipse + +MCP support available with Eclipse 2024-03+ and latest version of the GitHub Copilot plugin. + +### Remote Server (Recommended) + +The remote GitHub MCP server is hosted by GitHub and provides automatic updates with no local setup required. + +> **Note**: OAuth authentication for the remote GitHub server is not yet supported in Eclipse. You must use a Personal Access Token (PAT). + +#### Configuration Steps +1. Install GitHub Copilot extension from Eclipse Marketplace +2. Click the **GitHub Copilot icon** → **Edit Preferences** → **MCP** (under **GitHub Copilot**) +3. Add GitHub MCP server configuration: +```json +{ + "servers": { + "github": { + "url": "https://api.githubcopilot.com/mcp/", + "requestInit": { + "headers": { + "Authorization": "Bearer YOUR_GITHUB_PAT" + } + } + } + } +} +``` +4. Click the "Apply and Close" button in the preference dialog and the configuration will take effect automatically. + +### Local Server + +For users who prefer to run the GitHub MCP server locally. Requires Docker installed and running. + +#### Configuration +```json +{ + "servers": { + "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" + } + } + } +} +``` + +**Documentation:** [Eclipse Copilot plugin](https://marketplace.eclipse.org/content/github-copilot) + +--- + +## GitHub Personal Access Token + +For PAT authentication, see our [Personal Access Token documentation](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens) for setup instructions. + +--- + +## Usage + +After setup: +1. Restart your IDE completely +2. Open Agent mode in Copilot Chat +3. Try: *"List recent issues in this repository"* +4. Copilot can now access GitHub data and perform repository operations + +--- + +## Troubleshooting + +- **Connection issues**: Verify GitHub PAT permissions and IDE version compatibility +- **Authentication errors**: Check if your organization has enabled the MCP policy for Copilot +- **Tools not appearing**: Restart IDE after configuration changes and check error logs +- **Local server issues**: Ensure Docker is running for Docker-based setups diff --git a/docs/installation-guides/install-windsurf.md b/docs/installation-guides/install-windsurf.md new file mode 100644 index 000000000..8793e2edb --- /dev/null +++ b/docs/installation-guides/install-windsurf.md @@ -0,0 +1,107 @@ +# Install GitHub MCP Server in Windsurf + +## Prerequisites +1. Windsurf 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) + +The remote GitHub MCP server is hosted by GitHub at `https://api.githubcopilot.com/mcp/` and supports Streamable HTTP protocol. Windsurf currently supports PAT authentication only. + +### Streamable HTTP Configuration +Windsurf supports Streamable HTTP servers with a `serverUrl` field: + +```json +{ + "mcpServers": { + "github": { + "serverUrl": "https://api.githubcopilot.com/mcp/", + "headers": { + "Authorization": "Bearer YOUR_GITHUB_PAT" + } + } + } +} +``` + +## Local Server Setup + +### Docker Installation (Required) +**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. + +```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" + } + } + } +} +``` + +## Installation Steps + +### Via Plugin Store +1. Open Windsurf and navigate to Cascade +2. Click the **Plugins** icon or **hammer icon** (🔨) +3. Search for "GitHub MCP Server" +4. Click **Install** and enter your PAT when prompted +5. Click **Refresh** (🔄) + +### Manual Configuration +1. Click the hammer icon (🔨) in Cascade +2. Click **Configure** to open `~/.codeium/windsurf/mcp_config.json` +3. Add your chosen configuration from above +4. Save the file +5. Click **Refresh** (🔄) in the MCP toolbar + +## Configuration Details + +- **File path**: `~/.codeium/windsurf/mcp_config.json` +- **Scope**: Global configuration only (no per-project support) +- **Format**: Must be valid JSON (use a linter to verify) + +## Verification + +After installation: +1. Look for "1 available MCP server" in the MCP toolbar +2. Click the hammer icon to see available GitHub tools +3. Test with: "List my GitHub repositories" +4. Check for green dot next to the server name + +## Troubleshooting + +### Remote Server Issues +- **Authentication failures**: Verify PAT has correct scopes and hasn't expired +- **Connection errors**: Check firewall/proxy settings for HTTPS connections +- **Streamable HTTP not working**: Ensure you're using the correct `serverUrl` field format + +### 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 +- **Invalid JSON**: Validate with [jsonlint.com](https://jsonlint.com) +- **Tools not appearing**: Restart Windsurf completely +- **Check logs**: `~/.codeium/windsurf/logs/` + +## Important Notes + +- **Official repository**: [github/github-mcp-server](https://github.com/github/github-mcp-server) +- **Remote server URL**: `https://api.githubcopilot.com/mcp/` +- **Docker image**: `ghcr.io/github/github-mcp-server` (official and supported) +- **npm package**: `@modelcontextprotocol/server-github` (deprecated as of April 2025 - no longer functional) +- **Windsurf limitations**: No environment variable interpolation, global config only diff --git a/docs/policies-and-governance.md b/docs/policies-and-governance.md new file mode 100644 index 000000000..d7f52212a --- /dev/null +++ b/docs/policies-and-governance.md @@ -0,0 +1,216 @@ +# Policies & Governance for the GitHub MCP Server + +Organizations and enterprises have several existing control mechanisms for the GitHub MCP server on GitHub.com: +- MCP servers in Copilot Policy +- Copilot Editor Preview Policy (temporary) +- OAuth App Access Policies +- GitHub App Installation +- Personal Access Token (PAT) policies +- SSO Enforcement + +This document outlines how these policies apply to different deployment modes, authentication methods, and host applications – while providing guidance for managing GitHub MCP Server access across your organization. + +## How the GitHub MCP Server Works + +The GitHub MCP Server provides access to GitHub resources and capabilities through a standardized protocol, with flexible deployment and authentication options tailored to different use cases. It supports two deployment modes, both built on the same underlying codebase. + +### 1. Local GitHub MCP Server +* **Runs:** Locally alongside your IDE or application +* **Authentication & Controls:** Requires Personal Access Tokens (PATs). Users must generate and configure a PAT to connect. Managed via [PAT policies](https://docs.github.com/organizations/managing-programmatic-access-to-your-organization/setting-a-personal-access-token-policy-for-your-organization#restricting-access-by-personal-access-tokens). + * Can optionally use GitHub App installation tokens when embedded in a GitHub App-based tool (rare). + +**Supported SKUs:** Can be used with GitHub Enterprise Server (GHES) and GitHub Enterprise Cloud (GHEC). + +### 2. Remote GitHub MCP Server +* **Runs:** As a hosted service accessed over the internet +* **Authentication & Controls:** (determined by the chosen authentication method) + * **GitHub App Installation Tokens:** Uses a signed JWT to request installation access tokens (similar to the OAuth 2.0 client credentials flow) to operate as the application itself. Provides granular control via [installation](https://docs.github.com/apps/using-github-apps/installing-a-github-app-from-a-third-party#requirements-to-install-a-github-app), [permissions](https://docs.github.com/apps/creating-github-apps/registering-a-github-app/choosing-permissions-for-a-github-app) and [repository access controls](https://docs.github.com/apps/using-github-apps/reviewing-and-modifying-installed-github-apps#modifying-repository-access). + * **OAuth Authorization Code Flow:** Uses the standard OAuth 2.0 Authorization Code flow. Controlled via [OAuth App access policies](https://docs.github.com/organizations/managing-oauth-access-to-your-organizations-data/about-oauth-app-access-restrictions) for OAuth apps. For GitHub Apps that sign in ([are authorized by](https://docs.github.com/apps/using-github-apps/authorizing-github-apps)) a user, control access to your organization via [installation](https://docs.github.com/apps/using-github-apps/installing-a-github-app-from-a-third-party#requirements-to-install-a-github-app). + * **Personal Access Tokens (PATs):** Managed via [PAT policies](https://docs.github.com/organizations/managing-programmatic-access-to-your-organization/setting-a-personal-access-token-policy-for-your-organization#restricting-access-by-personal-access-tokens). + * **SSO enforcement:** Applies when using OAuth Apps, GitHub Apps, and PATs to access resources in organizations and enterprises with SSO enabled. Acts as an overlay control. Users must have a valid SSO session for your organization or enterprise when signing into the app or creating the token in order for the token to access your resources. Learn more in the [SSO documentation](https://docs.github.com/enterprise-cloud@latest/authentication/authenticating-with-single-sign-on/about-authentication-with-single-sign-on#about-oauth-apps-github-apps-and-sso). + +**Supported Platforms:** Currently available only on GitHub Enterprise Cloud (GHEC). Remote hosting for GHES is not supported at this time. + +> **Note:** This does not apply to the Local GitHub MCP Server, which uses PATs and does not rely on GitHub App installations. + +#### Enterprise Install Considerations + +- When using the Remote GitHub MCP Server, if authenticating with OAuth instead of PAT, each host application must have a registered GitHub App (or OAuth App) to authenticate on behalf of the user. +- Enterprises may choose to install these apps in multiple organizations (e.g., per team or department) to scope access narrowly, or at the enterprise level to centralize access control across all child organizations. +- Enterprise installation is only supported for GitHub Apps. OAuth Apps can only be installed on a per organization basis in multi-org enterprises. + +### Security Principles for Both Modes +* **Authentication:** Required for all operations, no anonymous access +* **Authorization:** Access enforced by GitHub's native permission model. Users and apps cannot use an MCP server to access more resources than they could otherwise access normally via the API. +* **Communication:** All data transmitted over HTTPS with optional SSE for real-time updates +* **Rate Limiting:** Subject to GitHub API rate limits based on authentication method +* **Token Storage:** Tokens should be stored securely using platform-appropriate credential storage +* **Audit Trail:** All underlying API calls are logged in GitHub's audit log when available + +For integration architecture and implementation details, see the [Host Integration Guide](https://github.com/github/github-mcp-server/blob/main/docs/host-integration.md). + +## Where It's Used + +The GitHub MCP server can be accessed in various environments (referred to as "host" applications): +* **First-party Hosts:** GitHub Copilot in VS Code, Visual Studio, JetBrains, Eclipse, and Xcode with integrated MCP support, as well as Copilot Coding Agent. +* **Third-party Hosts:** Editors outside the GitHub ecosystem, such as Claude, Cursor, Windsurf, and Cline, that support connecting to MCP servers, as well as AI chat applications like Claude Desktop and other AI assistants that connect to MCP servers to fetch GitHub context or execute write actions. + +## What It Can Access + +The MCP server accesses GitHub resources based on the permissions granted through the chosen authentication method (PAT, OAuth, or GitHub App). These may include: +* Repository contents (files, branches, commits) +* Issues and pull requests +* Organization and team metadata +* User profile information +* Actions workflow runs, logs, and statuses +* Security and vulnerability alerts (if explicitly granted) + +Access is always constrained by GitHub's public API permission model and the authenticated user's privileges. + +## Control Mechanisms + +### 1. Copilot Editors (first-party) → MCP Servers in Copilot Policy + +* **Policy:** MCP servers in Copilot +* **Location:** Enterprise/Org → Policies → Copilot +* **What it controls:** When disabled, **completely blocks all GitHub MCP Server access** (both remote and local) for affected Copilot editors. Currently applies to VS Code and Copilot Coding Agent, with more Copilot editors expected to migrate to this policy over time. +* **Impact when disabled:** Host applications governed by this policy cannot connect to the GitHub MCP Server through any authentication method (OAuth, PAT, or GitHub App). +* **What it does NOT affect:** + * MCP support in Copilot on IDEs that are still in public preview (Visual Studio, JetBrains, Xcode, Eclipse) + * Third-party IDE or host apps (like Claude, Cursor, Windsurf) not governed by GitHub's Copilot policies + * Community-authored MCP servers using GitHub's public APIs + +> **Important:** This policy provides comprehensive control over GitHub MCP Server access in Copilot editors. When disabled, users in affected applications will not be able to use the GitHub MCP Server regardless of deployment mode (remote or local) or authentication method. + +#### Temporary: Copilot Editor Preview Policy + +* **Policy:** Editor Preview Features +* **Status:** Being phased out as editors migrate to the "MCP servers in Copilot" policy above, and once the Remote GitHub MCP server goes GA +* **What it controls:** When disabled, prevents remaining Copilot editors from using the Remote GitHub MCP Server through OAuth connections in all first-party and third-party host applications (does not affect local deployments or PAT authentication) + +> **Note:** As Copilot editors migrate from the "Copilot Editor Preview" policy to the "MCP servers in Copilot" policy, the scope of control becomes more centralized, blocking both remote and local GitHub MCP Server access when disabled. Access in third-party hosts is governed separately by OAuth App, GitHub App, and PAT policies. + +### 2. Third-Party Host Apps (e.g., Claude, Cursor, Windsurf) → OAuth App or GitHub App Controls + +#### a. OAuth App Access Policies +* **Control Mechanism:** OAuth App access restrictions +* **Location:** Org → Settings → Third-party Access → OAuth app policy +* **How it works:** + * Organization admins must approve OAuth App requests before host apps can access organization data + * Only applies when the host registers an OAuth App AND the user connects via OAuth 2.0 flow + +#### b. GitHub App Installation +* **Control Mechanism:** GitHub App installation and permissions +* **Location:** Org → Settings → Third-party Access → GitHub Apps +* **What it controls:** Organization admins must install the app, select repositories, and grant permissions before the app can access organization-owned data or resources through the Remote GitHub Server. +* **How it works:** + * Organization admins must install the app, specify repositories, and approve permissions + * Only applies when the host registers a GitHub App AND the user authenticates through that flow + +> **Note:** The authentication methods available depend on what your host application supports. While PATs work with any remote MCP-compatible host, OAuth and GitHub App authentication are only available if the host has registered an app with GitHub. Check your host application's documentation or support for more info. + +### 3. PAT Access from Any Host → PAT Restrictions + +* **Types:** Fine-grained PATs (recommended) and Classic tokens (legacy) +* **Location:** + * User level: [Personal Settings → Developer Settings → Personal Access Tokens](https://docs.github.com/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens#fine-grained-personal-access-tokens) + * Enterprise/Organization level: [Enterprise/Organization → Settings → Personal Access Tokens](https://docs.github.com/organizations/managing-programmatic-access-to-your-organization/setting-a-personal-access-token-policy-for-your-organization) (to control PAT creation/access policies) +* **What it controls:** Applies to all host apps and both local & remote GitHub MCP servers when users authenticate via PAT. +* **How it works:** Access limited to the repositories and scopes selected on the token. +* **Limitations:** PATs do not adhere to OAuth App policies and GitHub App installation controls. They are user-scoped and not recommended for production automation. +* **Organization controls:** + * Classic PATs: Can be completely disabled organization-wide + * Fine-grained PATs: Cannot be disabled but require explicit approval for organization access + +> **Recommendation:** We recommend using fine-grained PATs over classic tokens. Classic tokens have broader scopes and can be disabled in organization settings. + +### 4. SSO Enforcement (overlay control) + +* **Location:** Enterprise/Organization → SSO settings +* **What it controls:** OAuth tokens and PATs must map to a recent SSO login to access SSO-protected organization data. +* **How it works:** Applies to ALL host apps when using OAuth or PATs. + +> **Exception:** Does NOT apply to GitHub App installation tokens (these are installation-scoped, not user-scoped) + +## Current Limitations + +While the GitHub MCP Server provides dynamic tooling and capabilities, the following enterprise governance features are not yet available: + +### Single Enterprise/Organization-Level Toggle + +GitHub does not provide a single toggle that blocks all GitHub MCP server traffic for every user. Admins can achieve equivalent coverage by combining the controls shown here: +* **First-party Copilot Editors (GitHub Copilot in VS Code, Visual Studio, JetBrains, Eclipse):** + * Disable the "MCP servers in Copilot" policy for comprehensive control + * Or disable the Editor Preview Features policy (for editors still using the legacy policy) +* **Third-party Host Applications:** + * Configure OAuth app restrictions + * Manage GitHub App installations +* **PAT Access in All Host Applications:** + * Implement fine-grained PAT policies (applies to both remote and local deployments) + +### MCP-Specific Audit Logging + +At present, MCP traffic appears in standard GitHub audit logs as normal API calls. Purpose-built logging for MCP is on the roadmap, but the following views are not yet available: +* Real-time list of active MCP connections +* Dashboards showing granular MCP usage data, like tools or host apps +* Granular, action-by-action audit logs + +Until those arrive, teams can continue to monitor MCP activity through existing API log entries and OAuth/GitHub App events. + +## Security Best Practices + +### For Organizations + +**GitHub App Management** +* Review [GitHub App installations](https://docs.github.com/apps/using-github-apps/reviewing-and-modifying-installed-github-apps) regularly +* Audit permissions and repository access +* Monitor installation events in audit logs +* Document approved GitHub Apps and their business purposes + +**OAuth App Governance** +* Manage [OAuth App access policies](https://docs.github.com/organizations/managing-oauth-access-to-your-organizations-data/about-oauth-app-access-restrictions) +* Establish review processes for approved applications +* Monitor which third-party applications are requesting access +* Maintain an allowlist of approved OAuth applications + +**Token Management** +* Mandate fine-grained Personal Access Tokens over classic tokens +* Establish token expiration policies (90 days maximum recommended) +* Implement automated token rotation reminders +* Review and enforce [PAT restrictions](https://docs.github.com/organizations/managing-programmatic-access-to-your-organization/setting-a-personal-access-token-policy-for-your-organization) at the appropriate level + +### For Developers and Users + +**Authentication Security** +* Prioritize OAuth 2.0 flows over long-lived tokens +* Prefer fine-grained PATs to PATs (Classic) +* Store tokens securely using platform-appropriate credential management +* Store credentials in secret management systems, not source code + +**Scope Minimization** +* Request only the minimum required scopes for your use case +* Regularly review and revoke unused token permissions +* Use repository-specific access instead of organization-wide access +* Document why each permission is needed for your integration + +## Resources + +**MCP:** +* [Model Context Protocol Specification](https://modelcontextprotocol.io/specification/2025-03-26) +* [Model Context Protocol Authorization](https://modelcontextprotocol.io/specification/draft/basic/authorization) + +**GitHub Governance & Controls:** +* [Managing OAuth App Access](https://docs.github.com/organizations/managing-oauth-access-to-your-organizations-data/about-oauth-app-access-restrictions) +* [GitHub App Permissions](https://docs.github.com/apps/creating-github-apps/registering-a-github-app/choosing-permissions-for-a-github-app) +* [Updating permissions for a GitHub App](https://docs.github.com/apps/using-github-apps/approving-updated-permissions-for-a-github-app) +* [PAT Policies](https://docs.github.com/organizations/managing-programmatic-access-to-your-organization/setting-a-personal-access-token-policy-for-your-organization) +* [Fine-grained PATs](https://docs.github.com/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens#fine-grained-personal-access-tokens) +* [Setting a PAT policy for your organization](https://docs.github.com/organizations/managing-oauth-access-to-your-organizations-data/about-oauth-app-access-restrictions) + +--- + +**Questions or Feedback?** + +Open an [issue in the github-mcp-server repository](https://github.com/github/github-mcp-server/issues) with the label "policies & governance" attached. + +This document reflects GitHub MCP Server policies as of July 2025. Policies and capabilities continue to evolve based on customer feedback and security best practices. 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 bc5a3fde3..64c5729ba 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/v72/github" + gogithub "github.com/google/go-github/v73/github" mcpClient "github.com/mark3labs/mcp-go/client" "github.com/mark3labs/mcp-go/mcp" "github.com/stretchr/testify/require" @@ -1338,7 +1338,7 @@ func TestPullRequestReviewCommentSubmit(t *testing.T) { // Add a file review comment addFileReviewCommentRequest := mcp.CallToolRequest{} - addFileReviewCommentRequest.Params.Name = "add_pull_request_review_comment_to_pending_review" + addFileReviewCommentRequest.Params.Name = "add_comment_to_pending_review" addFileReviewCommentRequest.Params.Arguments = map[string]any{ "owner": currentOwner, "repo": repoName, @@ -1350,12 +1350,12 @@ func TestPullRequestReviewCommentSubmit(t *testing.T) { t.Logf("Adding file review comment to pull request in %s/%s...", currentOwner, repoName) resp, err = mcpClient.CallTool(ctx, addFileReviewCommentRequest) - require.NoError(t, err, "expected to call 'add_pull_request_review_comment_to_pending_review' tool successfully") + require.NoError(t, err, "expected to call 'add_comment_to_pending_review' tool successfully") require.False(t, resp.IsError, fmt.Sprintf("expected result not to be an error: %+v", resp)) // Add a single line review comment addSingleLineReviewCommentRequest := mcp.CallToolRequest{} - addSingleLineReviewCommentRequest.Params.Name = "add_pull_request_review_comment_to_pending_review" + addSingleLineReviewCommentRequest.Params.Name = "add_comment_to_pending_review" addSingleLineReviewCommentRequest.Params.Arguments = map[string]any{ "owner": currentOwner, "repo": repoName, @@ -1370,12 +1370,12 @@ func TestPullRequestReviewCommentSubmit(t *testing.T) { t.Logf("Adding single line review comment to pull request in %s/%s...", currentOwner, repoName) resp, err = mcpClient.CallTool(ctx, addSingleLineReviewCommentRequest) - require.NoError(t, err, "expected to call 'add_pull_request_review_comment_to_pending_review' tool successfully") + require.NoError(t, err, "expected to call 'add_comment_to_pending_review' tool successfully") require.False(t, resp.IsError, fmt.Sprintf("expected result not to be an error: %+v", resp)) // Add a multiline review comment addMultilineReviewCommentRequest := mcp.CallToolRequest{} - addMultilineReviewCommentRequest.Params.Name = "add_pull_request_review_comment_to_pending_review" + addMultilineReviewCommentRequest.Params.Name = "add_comment_to_pending_review" addMultilineReviewCommentRequest.Params.Arguments = map[string]any{ "owner": currentOwner, "repo": repoName, @@ -1392,7 +1392,7 @@ func TestPullRequestReviewCommentSubmit(t *testing.T) { t.Logf("Adding multi line review comment to pull request in %s/%s...", currentOwner, repoName) resp, err = mcpClient.CallTool(ctx, addMultilineReviewCommentRequest) - require.NoError(t, err, "expected to call 'add_pull_request_review_comment_to_pending_review' tool successfully") + require.NoError(t, err, "expected to call 'add_comment_to_pending_review' tool successfully") require.False(t, resp.IsError, fmt.Sprintf("expected result not to be an error: %+v", resp)) // Submit the review diff --git a/go.mod b/go.mod index 4cc7682fd..3df6bf3d5 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/github/github-mcp-server go 1.23.7 require ( - github.com/google/go-github/v72 v72.0.0 + github.com/google/go-github/v73 v73.0.0 github.com/josephburnett/jd v1.9.2 github.com/mark3labs/mcp-go v0.32.0 github.com/migueleliasweb/go-github-mock v1.3.0 diff --git a/go.sum b/go.sum index 5e601d909..d77cdf0d9 100644 --- a/go.sum +++ b/go.sum @@ -20,8 +20,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/v72 v72.0.0 h1:FcIO37BLoVPBO9igQQ6tStsv2asG4IPcYFi655PPvBM= -github.com/google/go-github/v72 v72.0.0/go.mod h1:WWtw8GMRiL62mvIquf1kO3onRHeWWKmK01qdCY8c5fg= +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-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= diff --git a/internal/ghmcp/server.go b/internal/ghmcp/server.go index 568af10d1..d993b130a 100644 --- a/internal/ghmcp/server.go +++ b/internal/ghmcp/server.go @@ -17,7 +17,7 @@ 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/v72/github" + gogithub "github.com/google/go-github/v73/github" "github.com/mark3labs/mcp-go/mcp" "github.com/mark3labs/mcp-go/server" "github.com/shurcooL/githubv4" diff --git a/pkg/errors/error.go b/pkg/errors/error.go index 9d81e9010..c89ab2d79 100644 --- a/pkg/errors/error.go +++ b/pkg/errors/error.go @@ -4,7 +4,7 @@ import ( "context" "fmt" - "github.com/google/go-github/v72/github" + "github.com/google/go-github/v73/github" "github.com/mark3labs/mcp-go/mcp" ) diff --git a/pkg/errors/error_test.go b/pkg/errors/error_test.go index e7a5b6ea1..3498e3d8a 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/v72/github" + "github.com/google/go-github/v73/github" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) diff --git a/pkg/github/__toolsnaps__/add_pull_request_review_comment_to_pending_review.snap b/pkg/github/__toolsnaps__/add_comment_to_pending_review.snap similarity index 85% rename from pkg/github/__toolsnaps__/add_pull_request_review_comment_to_pending_review.snap rename to pkg/github/__toolsnaps__/add_comment_to_pending_review.snap index 454b9d0ba..08fa42df5 100644 --- a/pkg/github/__toolsnaps__/add_pull_request_review_comment_to_pending_review.snap +++ b/pkg/github/__toolsnaps__/add_comment_to_pending_review.snap @@ -1,9 +1,9 @@ { "annotations": { - "title": "Add comment to the requester's latest pending pull request review", + "title": "Add review comment to the requester's latest pending pull request review", "readOnlyHint": false }, - "description": "Add a comment to the requester's latest pending pull request review, a pending review needs to already exist to call this (check with the user if not sure).", + "description": "Add review comment to the requester's latest pending pull request review. A pending review needs to already exist to call this (check with the user if not sure).", "inputSchema": { "properties": { "body": { @@ -69,5 +69,5 @@ ], "type": "object" }, - "name": "add_pull_request_review_comment_to_pending_review" + "name": "add_comment_to_pending_review" } \ No newline at end of file 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__/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..e341f3e38 100644 --- a/pkg/github/__toolsnaps__/search_code.snap +++ b/pkg/github/__toolsnaps__/search_code.snap @@ -25,7 +25,7 @@ "minimum": 1, "type": "number" }, - "q": { + "query": { "description": "Search query using GitHub code search syntax", "type": "string" }, @@ -35,7 +35,7 @@ } }, "required": [ - "q" + "query" ], "type": "object" }, diff --git a/pkg/github/actions.go b/pkg/github/actions.go index 95b1ec7ba..19b56389c 100644 --- a/pkg/github/actions.go +++ b/pkg/github/actions.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/v72/github" + "github.com/google/go-github/v73/github" "github.com/mark3labs/mcp-go/mcp" "github.com/mark3labs/mcp-go/server" ) @@ -62,8 +62,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 +200,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 +503,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, }, } @@ -1025,8 +1025,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 f885ec5b9..cb33cbe6b 100644 --- a/pkg/github/actions_test.go +++ b/pkg/github/actions_test.go @@ -8,7 +8,7 @@ import ( "testing" "github.com/github/github-mcp-server/pkg/translations" - "github.com/google/go-github/v72/github" + "github.com/google/go-github/v73/github" "github.com/migueleliasweb/go-github-mock/src/mock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" diff --git a/pkg/github/code_scanning.go b/pkg/github/code_scanning.go index 3b07692c0..6b15c0c45 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/v72/github" + "github.com/google/go-github/v73/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 bd76ccbae..66f6fd6cc 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/v72/github" + "github.com/google/go-github/v73/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_test.go b/pkg/github/context_tools_test.go index 03af4175d..56f61e936 100644 --- a/pkg/github/context_tools_test.go +++ b/pkg/github/context_tools_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/v72/github" + "github.com/google/go-github/v73/github" "github.com/migueleliasweb/go-github-mock/src/mock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" diff --git a/pkg/github/dependabot.go b/pkg/github/dependabot.go index af21b83d1..c2a4d5b0d 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/v72/github" + "github.com/google/go-github/v73/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 f7c091981..8a7270d7f 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/v72/github" + "github.com/google/go-github/v73/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 3e53a633b..fce07ecdb 100644 --- a/pkg/github/discussions.go +++ b/pkg/github/discussions.go @@ -7,12 +7,116 @@ import ( "github.com/github/github-mcp-server/pkg/translations" "github.com/go-viper/mapstructure/v2" - "github.com/google/go-github/v72/github" + "github.com/google/go-github/v73/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")), @@ -31,9 +135,17 @@ func ListDiscussions(getGQLClient GetGQLClientFn, t translations.TranslationHelp 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 @@ -43,107 +155,96 @@ func ListDiscussions(getGQLClient GetGQLClientFn, t translations.TranslationHelp return mcp.NewToolResultError(err.Error()), nil } - // 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) } @@ -236,6 +337,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 +350,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 +383,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 +398,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) } @@ -301,55 +450,22 @@ func ListDiscussionCategories(getGQLClient GetGQLClientFn, t translations.Transl 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"), - ), ), 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 + Owner string + Repo string } if err := mapstructure.Decode(request.Params.Arguments, ¶ms); 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 - } - if params.Before != "" && params.First != 0 { - return mcp.NewToolResultError("'before' cannot be used with 'first'. Did you mean to use 'after' instead?"), nil - } - 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 +473,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), + "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 +499,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 5132c6ce0..aefaf2f8c 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/v72/github" + "github.com/google/go-github/v73/github" "github.com/shurcooL/githubv4" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -17,22 +17,126 @@ 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"}, + }, } + + // 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, + }, }, }) mockErrorRepoNotFound = githubv4mock.ErrorResponse("repository not found") @@ -40,62 +144,64 @@ var ( 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.Contains(t, toolDef.InputSchema.Properties, "orderBy") + assert.Contains(t, toolDef.InputSchema.Properties, "direction") 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)"` + // Variables matching what GraphQL receives after JSON marshaling/unmarshaling + varsListAll := map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "first": float64(30), + "after": (*string)(nil), } - // 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)"` + varsRepoNotFound := map[string]interface{}{ + "owner": "owner", + "repo": "nonexistent-repo", + "first": float64(30), + "after": (*string)(nil), } - varsListAll := map[string]interface{}{ - "owner": githubv4.String("owner"), - "repo": githubv4.String("repo"), + varsDiscussionsFiltered := map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "categoryId": "DIC_kwDOABC123", + "first": float64(30), + "after": (*string)(nil), } - varsRepoNotFound := map[string]interface{}{ - "owner": githubv4.String("owner"), - "repo": githubv4.String("nonexistent-repo"), + varsOrderByCreatedAsc := map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "orderByField": "CREATED_AT", + "orderByDirection": "ASC", + "first": float64(30), + "after": (*string)(nil), } - varsDiscussionsFiltered := map[string]interface{}{ - "owner": githubv4.String("owner"), - "repo": githubv4.String("repo"), - "categoryId": githubv4.ID("DIC_kwDOABC123"), + 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), } tests := []struct { @@ -104,6 +210,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 +231,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{}{ @@ -135,21 +316,40 @@ func Test_ListDiscussions(t *testing.T) { }, } + // 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) } @@ -167,15 +367,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 +409,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,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 @@ -250,7 +455,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) @@ -287,22 +492,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 +512,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,31 +539,38 @@ 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)"` - } + // 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 matching what GraphQL receives after JSON marshaling/unmarshaling vars := map[string]interface{}{ - "owner": githubv4.String("owner"), - "repo": githubv4.String("repo"), + "owner": "owner", + "repo": "repo", + "first": float64(25), } + mockResp := githubv4mock.DataResponse(map[string]any{ "repository": map[string]any{ "discussionCategories": map[string]any{ @@ -363,10 +578,17 @@ func Test_ListDiscussionCategories(t *testing.T) { {"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) + matcher := githubv4mock.NewQueryMatcher(qListCategories, vars, mockResp) httpClient := githubv4mock.NewMockedHTTPClient(matcher) gqlClient := githubv4.NewClient(httpClient) @@ -382,11 +604,21 @@ func Test_ListDiscussionCategories(t *testing.T) { require.NoError(t, err) 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"]) + + 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.Len(t, response.Categories, 2) + assert.Equal(t, "123", response.Categories[0]["id"]) + assert.Equal(t, "CategoryOne", response.Categories[0]["name"]) + assert.Equal(t, "456", response.Categories[1]["id"]) + assert.Equal(t, "CategoryTwo", response.Categories[1]["name"]) } diff --git a/pkg/github/issues.go b/pkg/github/issues.go index 9d51aeb50..f718c37cb 100644 --- a/pkg/github/issues.go +++ b/pkg/github/issues.go @@ -9,9 +9,10 @@ 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/v72/github" + "github.com/google/go-github/v73/github" "github.com/mark3labs/mcp-go/mcp" "github.com/mark3labs/mcp-go/server" "github.com/shurcooL/githubv4" @@ -153,6 +154,408 @@ 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) + } + + // Create the request body + requestBody := map[string]interface{}{ + "sub_issue_id": subIssueID, + } + reqBodyBytes, err := json.Marshal(requestBody) + if err != nil { + return nil, fmt.Errorf("failed to marshal request body: %w", err) + } + + // Create the HTTP request + url := fmt.Sprintf("%srepos/%s/%s/issues/%d/sub_issue", + client.BaseURL.String(), owner, repo, issueNumber) + req, err := http.NewRequestWithContext(ctx, "DELETE", url, strings.NewReader(string(reqBodyBytes))) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + req.Header.Set("Accept", "application/vnd.github+json") + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-GitHub-Api-Version", "2022-11-28") + + httpClient := client.Client() // Use authenticated GitHub client + resp, err := httpClient.Do(req) + if err != nil { + var ghResp *github.Response + if resp != nil { + ghResp = &github.Response{Response: resp} + } + return ghErrors.NewGitHubAPIErrorResponse(ctx, + "failed to remove sub-issue", + ghResp, + err, + ), nil + } + defer func() { _ = resp.Body.Close() }() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return mcp.NewToolResultError(fmt.Sprintf("failed to remove sub-issue: %s", string(body))), nil + } + + // Parse and re-marshal to ensure consistent formatting + var result interface{} + if err := json.Unmarshal(body, &result); err != nil { + return nil, fmt.Errorf("failed to unmarshal response: %w", err) + } + + r, err := json.Marshal(result) + 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", @@ -630,8 +1033,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, }, } diff --git a/pkg/github/issues_test.go b/pkg/github/issues_test.go index a6facbe2f..2bdb89b06 100644 --- a/pkg/github/issues_test.go +++ b/pkg/github/issues_test.go @@ -11,7 +11,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/v72/github" + "github.com/google/go-github/v73/github" "github.com/migueleliasweb/go-github-mock/src/mock" "github.com/shurcooL/githubv4" "github.com/stretchr/testify/assert" @@ -38,6 +38,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 +114,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) }) } } @@ -1629,3 +1635,969 @@ 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) + }) + } +} diff --git a/pkg/github/notifications.go b/pkg/github/notifications.go index b6b6bfd79..fdd418098 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/v72/github" + "github.com/google/go-github/v73/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 a83df3ed8..1d2382369 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/v72/github" + "github.com/google/go-github/v73/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 32c7e850c..47b7c6bd2 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/v72/github" + "github.com/google/go-github/v73/github" "github.com/mark3labs/mcp-go/mcp" "github.com/mark3labs/mcp-go/server" "github.com/shurcooL/githubv4" @@ -403,8 +403,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, }, } @@ -622,8 +622,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 { @@ -1151,12 +1151,12 @@ func CreatePendingPullRequestReview(getGQLClient GetGQLClientFn, t translations. } } -// AddPullRequestReviewCommentToPendingReview creates a tool to add a comment to a pull request review. -func AddPullRequestReviewCommentToPendingReview(getGQLClient GetGQLClientFn, t translations.TranslationHelperFunc) (mcp.Tool, server.ToolHandlerFunc) { - return mcp.NewTool("add_pull_request_review_comment_to_pending_review", - mcp.WithDescription(t("TOOL_ADD_PULL_REQUEST_REVIEW_COMMENT_TO_PENDING_REVIEW_DESCRIPTION", "Add a comment to the requester's latest pending pull request review, a pending review needs to already exist to call this (check with the user if not sure).")), +// AddCommentToPendingReview creates a tool to add a comment to a pull request review. +func AddCommentToPendingReview(getGQLClient GetGQLClientFn, t translations.TranslationHelperFunc) (mcp.Tool, server.ToolHandlerFunc) { + return mcp.NewTool("add_comment_to_pending_review", + mcp.WithDescription(t("TOOL_ADD_COMMENT_TO_PENDING_REVIEW_DESCRIPTION", "Add review comment to the requester's latest pending pull request review. A pending review needs to already exist to call this (check with the user if not sure).")), mcp.WithToolAnnotation(mcp.ToolAnnotation{ - Title: t("TOOL_ADD_PULL_REQUEST_REVIEW_COMMENT_TO_PENDING_REVIEW_USER_TITLE", "Add comment to the requester's latest pending pull request review"), + Title: t("TOOL_ADD_COMMENT_TO_PENDING_REVIEW_USER_TITLE", "Add review comment to the requester's latest pending pull request review"), ReadOnlyHint: ToBoolPtr(false), }), // Ideally, for performance sake this would just accept the pullRequestReviewID. However, we would need to diff --git a/pkg/github/pullrequests_test.go b/pkg/github/pullrequests_test.go index 30341e86c..42fd5bf03 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/v72/github" + "github.com/google/go-github/v73/github" "github.com/shurcooL/githubv4" "github.com/migueleliasweb/go-github-mock/src/mock" @@ -2137,10 +2137,10 @@ func TestAddPullRequestReviewCommentToPendingReview(t *testing.T) { // Verify tool definition once mockClient := githubv4.NewClient(nil) - tool, _ := AddPullRequestReviewCommentToPendingReview(stubGetGQLClientFn(mockClient), translations.NullTranslationHelper) + tool, _ := AddCommentToPendingReview(stubGetGQLClientFn(mockClient), translations.NullTranslationHelper) require.NoError(t, toolsnaps.Test(tool.Name, tool)) - assert.Equal(t, "add_pull_request_review_comment_to_pending_review", tool.Name) + assert.Equal(t, "add_comment_to_pending_review", tool.Name) assert.NotEmpty(t, tool.Description) assert.Contains(t, tool.InputSchema.Properties, "owner") assert.Contains(t, tool.InputSchema.Properties, "repo") @@ -2222,7 +2222,7 @@ func TestAddPullRequestReviewCommentToPendingReview(t *testing.T) { // Setup client with mock client := githubv4.NewClient(tc.mockedClient) - _, handler := AddPullRequestReviewCommentToPendingReview(stubGetGQLClientFn(client), translations.NullTranslationHelper) + _, handler := AddCommentToPendingReview(stubGetGQLClientFn(client), translations.NullTranslationHelper) // Create call request request := createMCPRequest(tc.requestArgs) diff --git a/pkg/github/repositories.go b/pkg/github/repositories.go index 186bd2321..ecd36d7e0 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/v72/github" + "github.com/google/go-github/v73/github" "github.com/mark3labs/mcp-go/mcp" "github.com/mark3labs/mcp-go/server" ) @@ -58,8 +58,8 @@ func GetCommit(getClient GetClientFn, t translations.TranslationHelperFunc) (too } opts := &github.ListOptions{ - Page: pagination.page, - PerPage: pagination.perPage, + Page: pagination.Page, + PerPage: pagination.PerPage, } client, err := getClient(ctx) @@ -139,7 +139,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 +147,7 @@ func ListCommits(getClient GetClientFn, t translations.TranslationHelperFunc) (t SHA: sha, Author: author, ListOptions: github.ListOptions{ - Page: pagination.page, + Page: pagination.Page, PerPage: perPage, }, } @@ -217,8 +217,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, }, } @@ -507,6 +507,24 @@ func GetFileContents(getClient GetClientFn, getRawClient raw.GetRawClientFn, t t // If the path is (most likely) not to be a directory, we will // first try to get the raw content from the GitHub raw content API. if path != "" && !strings.HasSuffix(path, "/") { + // First, get file info from Contents API to retrieve SHA + var fileSHA string + opts := &github.RepositoryContentGetOptions{Ref: ref} + fileContent, _, respContents, err := client.Repositories.GetContents(ctx, owner, repo, path, opts) + if respContents != nil { + defer func() { _ = respContents.Body.Close() }() + } + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + "failed to get file SHA", + respContents, + err, + ), nil + } + if fileContent == nil || fileContent.SHA == nil { + return mcp.NewToolResultError("file content SHA is nil"), nil + } + fileSHA = *fileContent.SHA rawClient, err := getRawClient(ctx) if err != nil { @@ -548,18 +566,28 @@ func GetFileContents(getClient GetClientFn, getRawClient raw.GetRawClientFn, t t } if strings.HasPrefix(contentType, "application") || strings.HasPrefix(contentType, "text") { - return mcp.NewToolResultResource("successfully downloaded text file", mcp.TextResourceContents{ + result := mcp.TextResourceContents{ URI: resourceURI, Text: string(body), MIMEType: contentType, - }), nil + } + // Include SHA in the result metadata + if fileSHA != "" { + return mcp.NewToolResultResource(fmt.Sprintf("successfully downloaded text file (SHA: %s)", fileSHA), result), nil + } + return mcp.NewToolResultResource("successfully downloaded text file", result), nil } - return mcp.NewToolResultResource("successfully downloaded binary file", mcp.BlobResourceContents{ + result := mcp.BlobResourceContents{ URI: resourceURI, Blob: base64.StdEncoding.EncodeToString(body), MIMEType: contentType, - }), nil + } + // Include SHA in the result metadata + if fileSHA != "" { + return mcp.NewToolResultResource(fmt.Sprintf("successfully downloaded binary file (SHA: %s)", fileSHA), result), nil + } + return mcp.NewToolResultResource("successfully downloaded binary file", result), nil } } @@ -1170,8 +1198,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) diff --git a/pkg/github/repositories_test.go b/pkg/github/repositories_test.go index 4977bb0a9..1572a12f4 100644 --- a/pkg/github/repositories_test.go +++ b/pkg/github/repositories_test.go @@ -12,7 +12,7 @@ import ( "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/v72/github" + "github.com/google/go-github/v73/github" "github.com/mark3labs/mcp-go/mcp" "github.com/migueleliasweb/go-github-mock/src/mock" "github.com/stretchr/testify/assert" @@ -76,6 +76,20 @@ func Test_GetFileContents(t *testing.T) { _, _ = w.Write([]byte(`{"ref": "refs/heads/main", "object": {"sha": ""}}`)) }), ), + mock.WithRequestMatchHandler( + mock.GetReposContentsByOwnerByRepoByPath, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + fileContent := &github.RepositoryContent{ + Name: github.Ptr("README.md"), + Path: github.Ptr("README.md"), + SHA: github.Ptr("abc123"), + Type: github.Ptr("file"), + } + contentBytes, _ := json.Marshal(fileContent) + _, _ = w.Write(contentBytes) + }), + ), mock.WithRequestMatchHandler( raw.GetRawReposContentsByOwnerByRepoByBranchByPath, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { @@ -107,6 +121,20 @@ func Test_GetFileContents(t *testing.T) { _, _ = w.Write([]byte(`{"ref": "refs/heads/main", "object": {"sha": ""}}`)) }), ), + mock.WithRequestMatchHandler( + mock.GetReposContentsByOwnerByRepoByPath, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + fileContent := &github.RepositoryContent{ + Name: github.Ptr("test.png"), + Path: github.Ptr("test.png"), + SHA: github.Ptr("def456"), + Type: github.Ptr("file"), + } + contentBytes, _ := json.Marshal(fileContent) + _, _ = w.Write(contentBytes) + }), + ), mock.WithRequestMatchHandler( raw.GetRawReposContentsByOwnerByRepoByBranchByPath, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { diff --git a/pkg/github/repository_resource.go b/pkg/github/repository_resource.go index a454db630..70ca6ba65 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/v72/github" + "github.com/google/go-github/v73/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 0e9f018e7..2e3e911a9 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/v72/github" + "github.com/google/go-github/v73/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 a72b38bc6..476ac0151 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/v72/github" + "github.com/google/go-github/v73/github" "github.com/mark3labs/mcp-go/mcp" "github.com/mark3labs/mcp-go/server" ) @@ -39,8 +39,8 @@ func SearchRepositories(getClient GetClientFn, t translations.TranslationHelperF opts := &github.SearchOptions{ ListOptions: github.ListOptions{ - Page: pagination.page, - PerPage: pagination.perPage, + Page: pagination.Page, + PerPage: pagination.PerPage, }, } @@ -83,7 +83,7 @@ func SearchCode(getClient GetClientFn, t translations.TranslationHelperFunc) (to 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"), ), @@ -97,7 +97,7 @@ func SearchCode(getClient GetClientFn, t translations.TranslationHelperFunc) (to 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 +118,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, }, } @@ -193,8 +193,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, }, } diff --git a/pkg/github/search_test.go b/pkg/github/search_test.go index bfd014993..9ea8e71ec 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/v72/github" + "github.com/google/go-github/v73/github" "github.com/migueleliasweb/go-github-mock/src/mock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -173,12 +173,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 +227,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 +251,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 +268,7 @@ func Test_SearchCode(t *testing.T) { ), ), requestArgs: map[string]interface{}{ - "q": "invalid:query", + "query": "invalid:query", }, expectError: true, expectedErrMsg: "failed to search code", diff --git a/pkg/github/search_utils.go b/pkg/github/search_utils.go index 6642dad8f..a6ff1f782 100644 --- a/pkg/github/search_utils.go +++ b/pkg/github/search_utils.go @@ -7,7 +7,7 @@ import ( "io" "net/http" - "github.com/google/go-github/v72/github" + "github.com/google/go-github/v73/github" "github.com/mark3labs/mcp-go/mcp" ) @@ -56,8 +56,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/secret_scanning.go b/pkg/github/secret_scanning.go index bea6df2ae..dc199b4e6 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/v72/github" + "github.com/google/go-github/v73/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 38b573e09..96b281830 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/v72/github" + "github.com/google/go-github/v73/github" "github.com/migueleliasweb/go-github-mock/src/mock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" diff --git a/pkg/github/server.go b/pkg/github/server.go index e7b831791..193336b75 100644 --- a/pkg/github/server.go +++ b/pkg/github/server.go @@ -5,7 +5,7 @@ import ( "errors" "fmt" - "github.com/google/go-github/v72/github" + "github.com/google/go-github/v73/github" "github.com/mark3labs/mcp-go/mcp" "github.com/mark3labs/mcp-go/server" ) @@ -174,9 +174,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 +191,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 +247,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 3f00d7b24..7f8f29c0d 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/v72/github" + "github.com/google/go-github/v73/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 a469b7678..e01b7cc40 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/v72/github" + "github.com/google/go-github/v73/github" "github.com/mark3labs/mcp-go/server" "github.com/shurcooL/githubv4" ) @@ -53,12 +53,16 @@ func DefaultToolsetGroup(readOnly bool, getClient GetClientFn, getGQLClient GetG toolsets.NewServerTool(SearchIssues(getClient, t)), toolsets.NewServerTool(ListIssues(getClient, t)), toolsets.NewServerTool(GetIssueComments(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)), + toolsets.NewServerTool(AddSubIssue(getClient, t)), + toolsets.NewServerTool(RemoveSubIssue(getClient, t)), + toolsets.NewServerTool(ReprioritizeSubIssue(getClient, t)), ).AddPrompts(toolsets.NewServerPrompt(AssignCodingAgentPrompt(t))) users := toolsets.NewToolset("users", "GitHub User related tools"). AddReadTools( @@ -89,7 +93,7 @@ func DefaultToolsetGroup(readOnly bool, getClient GetClientFn, getGQLClient GetG // Reviews toolsets.NewServerTool(CreateAndSubmitPullRequestReview(getGQLClient, t)), toolsets.NewServerTool(CreatePendingPullRequestReview(getGQLClient, t)), - toolsets.NewServerTool(AddPullRequestReviewCommentToPendingReview(getGQLClient, t)), + toolsets.NewServerTool(AddCommentToPendingReview(getGQLClient, t)), toolsets.NewServerTool(SubmitPendingPullRequestReview(getGQLClient, t)), toolsets.NewServerTool(DeletePendingPullRequestReview(getGQLClient, t)), ) diff --git a/pkg/raw/raw.go b/pkg/raw/raw.go index 17995ccae..af669c905 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/v72/github" + gogithub "github.com/google/go-github/v73/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 f02033159..18a48130d 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/v72/github" + "github.com/google/go-github/v73/github" "github.com/migueleliasweb/go-github-mock/src/mock" "github.com/stretchr/testify/require" ) diff --git a/third-party-licenses.darwin.md b/third-party-licenses.darwin.md index e616fa560..6a9f895cb 100644 --- a/third-party-licenses.darwin.md +++ b/third-party-licenses.darwin.md @@ -13,7 +13,7 @@ Some packages may only be included on certain architectures or operating systems - [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/v72/github](https://pkg.go.dev/github.com/google/go-github/v72/github) ([BSD-3-Clause](https://github.com/google/go-github/blob/v72.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-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)) diff --git a/third-party-licenses.linux.md b/third-party-licenses.linux.md index e616fa560..6a9f895cb 100644 --- a/third-party-licenses.linux.md +++ b/third-party-licenses.linux.md @@ -13,7 +13,7 @@ Some packages may only be included on certain architectures or operating systems - [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/v72/github](https://pkg.go.dev/github.com/google/go-github/v72/github) ([BSD-3-Clause](https://github.com/google/go-github/blob/v72.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-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)) diff --git a/third-party-licenses.windows.md b/third-party-licenses.windows.md index d34ce2449..505c2d83e 100644 --- a/third-party-licenses.windows.md +++ b/third-party-licenses.windows.md @@ -13,7 +13,7 @@ Some packages may only be included on certain architectures or operating systems - [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/v72/github](https://pkg.go.dev/github.com/google/go-github/v72/github) ([BSD-3-Clause](https://github.com/google/go-github/blob/v72.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-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)) diff --git a/third-party/github.com/google/go-github/v72/github/LICENSE b/third-party/github.com/google/go-github/v73/github/LICENSE similarity index 100% rename from third-party/github.com/google/go-github/v72/github/LICENSE rename to third-party/github.com/google/go-github/v73/github/LICENSE pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy