diff --git a/.github/workflows/docs-check.yml b/.github/workflows/docs-check.yml new file mode 100644 index 000000000..c28c528b2 --- /dev/null +++ b/.github/workflows/docs-check.yml @@ -0,0 +1,47 @@ +name: Documentation Check + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +permissions: + contents: read + +jobs: + docs-check: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: 'go.mod' + + - name: Build docs generator + run: go build -o github-mcp-server ./cmd/github-mcp-server + + - name: Generate documentation + run: ./github-mcp-server generate-docs + + - name: Check for documentation changes + run: | + if ! git diff --exit-code README.md; then + echo "❌ Documentation is out of date!" + echo "" + echo "The generated documentation differs from what's committed." + echo "Please run the following command to update the documentation:" + echo "" + echo " go run ./cmd/github-mcp-server generate-docs" + echo "" + echo "Then commit the changes." + echo "" + echo "Changes detected:" + git diff README.md + exit 1 + else + echo "✅ Documentation is up to date!" + fi diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index cd67b9653..e3ef25022 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -1,4 +1,4 @@ -name: Unit Tests +name: Build and Test Go Project on: [push, pull_request] permissions: @@ -26,7 +26,7 @@ jobs: run: go mod download - name: Run unit tests - run: go test -race ./... + run: script/test - name: Build run: go build -v ./cmd/github-mcp-server diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml deleted file mode 100644 index 9fa416abd..000000000 --- a/.github/workflows/lint.yaml +++ /dev/null @@ -1,45 +0,0 @@ -name: Lint -on: - push: - pull_request: - -permissions: - contents: read - -jobs: - lint: - runs-on: ubuntu-latest - - steps: - - name: Check out code - uses: actions/checkout@v4 - - - name: Set up Go - uses: actions/setup-go@v5 - with: - go-version-file: 'go.mod' - - - name: Verify dependencies - run: | - go mod verify - go mod download - - - name: Run checks - run: | - STATUS=0 - assert-nothing-changed() { - local diff - "$@" >/dev/null || return 1 - if ! diff="$(git diff -U1 --color --exit-code)"; then - printf '\e[31mError: running `\e[1m%s\e[22m` results in modifications that you must check into version control:\e[0m\n%s\n\n' "$*" "$diff" >&2 - git checkout -- . - STATUS=1 - fi - } - assert-nothing-changed go mod tidy - exit $STATUS - - - name: golangci-lint - uses: golangci/golangci-lint-action@4afd733a84b1f43292c63897423277bb7f4313a9 - with: - version: v2.1.6 diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 000000000..b40193e72 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,23 @@ +name: golangci-lint +on: + push: + branches: + - main + pull_request: + +permissions: + contents: read + +jobs: + golangci: + name: lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: stable + - name: golangci-lint + uses: golangci/golangci-lint-action@v8 + with: + version: v2.1 diff --git a/.gitignore b/.gitignore index 12649366d..0ad709cbf 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,7 @@ __debug_bin* # Go vendor +bin/ + +# macOS +.DS_Store \ No newline at end of file diff --git a/.golangci.yml b/.golangci.yml index 61302f6f7..f86326cfa 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -1,47 +1,37 @@ -# https://golangci-lint.run/usage/configuration version: "2" - run: - timeout: 5m - tests: true concurrency: 4 - + tests: true linters: enable: - - govet - - errcheck - - staticcheck - - revive - - ineffassign - - unused - - misspell - - nakedret - bodyclose - gocritic - - makezero - gosec + - makezero + - misspell + - nakedret + - revive + exclusions: + generated: lax + presets: + - comments + - common-false-positives + - legacy + - std-error-handling + paths: + - third_party$ + - builtin$ + - examples$ settings: staticcheck: checks: - - all - - '-QF1008' # Allow embedded structs to be referenced by field - - '-ST1000' # Do not require package comments - revive: - rules: - - name: exported - disabled: true - - name: exported - disabled: true - - name: package-comments - disabled: true - + - "all" + - -QF1008 + - -ST1000 formatters: - enable: - - gofmt - - goimports - -output: - formats: - text: - print-linter-name: true - print-issued-lines: true + exclusions: + generated: lax + paths: + - third_party$ + - builtin$ + - examples$ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6fa9c2ebe..2307f6a28 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -10,24 +10,36 @@ 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. -1. install Go [through download](https://go.dev/doc/install) | [through Homebrew](https://formulae.brew.sh/formula/go) -1. [install golangci-lint v2](https://golangci-lint.run/welcome/install/#local-installation) +1. Install Go [through download](https://go.dev/doc/install) | [through Homebrew](https://formulae.brew.sh/formula/go) +2. [Install golangci-lint v2](https://golangci-lint.run/welcome/install/#local-installation) ## Submitting a pull request -> **Important**: Please open your pull request against the `next` branch, not `main`. The `next` branch is where we integrate new features and changes before they are merged to `main`. - 1. [Fork][fork] and clone the repository -1. Make sure the tests pass on your machine: `go test -v ./...` -1. Make sure linter passes on your machine: `golangci-lint run` -1. Create a new branch: `git checkout -b my-branch-name` -1. Make your change, add tests, and make sure the tests and linter still pass -1. Push to your fork and [submit a pull request][pr] targeting the `next` branch -1. Pat yourself on the back and wait for your pull request to be reviewed and merged. +2. Make sure the tests pass on your machine: `go test -v ./...` +3. Make sure linter passes on your machine: `golangci-lint run` +4. Create a new branch: `git checkout -b my-branch-name` +5. Add your changes and tests, and make sure the Action workflows still pass + - Run linter: `script/lint` + - Update snapshots and run tests: `UPDATE_TOOLSNAPS=true go test ./...` + - Update readme documentation: `script/generate-docs` +6. Push to your fork and [submit a pull request][pr] targeting the `main` branch +7. Pat yourself on the back and wait for your pull request to be reviewed and merged. Here are a few things you can do that will increase the likelihood of your pull request being accepted: diff --git a/README.md b/README.md index 9706ef664..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 - -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: - - - - - - - -
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 " - } - } -} -``` +### 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 -
+> **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. -> **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 above. 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. Once you complete that flow, toggle Agent mode (located by the Copilot Chat text input) and the server will start. +More about using MCP server tools in VS Code's [agent mode documentation](https://code.visualstudio.com/docs/copilot/chat/mcp-servers). -### Usage in other MCP Hosts +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 @@ -263,706 +278,769 @@ _Toolsets are not limited to Tools. Relevant MCP Resources and Prompts are also The following sets of tools are available (all are on by default): + | Toolset | Description | | ----------------------- | ------------------------------------------------------------- | -| `actions` | GitHub Actions workflows and CI/CD operations | | `context` | **Strongly recommended**: Tools that provide context about the current user and GitHub context you are operating in | -| `code_security` | Code scanning alerts and security features | -| `issues` | Issue-related tools (create, read, update, comment) | -| `notifications` | GitHub Notifications related tools | -| `pull_requests` | Pull request operations (create, merge, review) | -| `repos` | Repository-related tools (file operations, branches, commits) | -| `secret_protection` | Secret protection related tools, such as GitHub Secret Scanning | -| `users` | Anything relating to GitHub Users | -| `experiments` | Experimental features (not considered stable) | - - -#### 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**: +| `actions` | GitHub Actions workflows and CI/CD operations | +| `code_security` | Code security related tools, such as GitHub Code Scanning | +| `dependabot` | Dependabot tools | +| `discussions` | GitHub Discussions related tools | +| `experiments` | Experimental features that are not considered stable yet | +| `issues` | GitHub Issues related tools | +| `notifications` | GitHub Notifications related tools | +| `orgs` | GitHub Organization related tools | +| `pull_requests` | GitHub Pull Request related tools | +| `repos` | GitHub Repository related tools | +| `secret_protection` | Secret protection related tools, such as GitHub Secret Scanning | +| `users` | GitHub User related tools | + - ```bash - github-mcp-server --toolsets repos,issues,pull_requests,actions,code_security - ``` +## Tools -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 +Actions -When using Docker, you can pass the toolsets as environment variables: +- **cancel_workflow_run** - Cancel workflow run + - `owner`: Repository owner (string, required) + - `repo`: Repository name (string, required) + - `run_id`: The unique identifier of the workflow run (number, required) -```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 -``` +- **delete_workflow_run_logs** - Delete workflow logs + - `owner`: Repository owner (string, required) + - `repo`: Repository name (string, required) + - `run_id`: The unique identifier of the workflow run (number, required) -### The "all" Toolset +- **download_workflow_run_artifact** - Download workflow artifact + - `artifact_id`: The unique identifier of the artifact (number, required) + - `owner`: Repository owner (string, required) + - `repo`: Repository name (string, required) -The special toolset `all` can be provided to enable all available toolsets regardless of any other configuration: +- **get_job_logs** - Get job logs + - `failed_only`: When true, gets logs for all failed jobs in run_id (boolean, optional) + - `job_id`: The unique identifier of the workflow job (required for single job logs) (number, optional) + - `owner`: Repository owner (string, required) + - `repo`: Repository name (string, required) + - `return_content`: Returns actual log content instead of URLs (boolean, optional) + - `run_id`: Workflow run ID (required when using failed_only) (number, optional) + - `tail_lines`: Number of lines to return from the end of the log (number, optional) -```bash -./github-mcp-server --toolsets all -``` +- **get_workflow_run** - Get workflow run + - `owner`: Repository owner (string, required) + - `repo`: Repository name (string, required) + - `run_id`: The unique identifier of the workflow run (number, required) -Or using the environment variable: +- **get_workflow_run_logs** - Get workflow run logs + - `owner`: Repository owner (string, required) + - `repo`: Repository name (string, required) + - `run_id`: The unique identifier of the workflow run (number, required) -```bash -GITHUB_TOOLSETS="all" ./github-mcp-server -``` +- **get_workflow_run_usage** - Get workflow usage + - `owner`: Repository owner (string, required) + - `repo`: Repository name (string, required) + - `run_id`: The unique identifier of the workflow run (number, required) -## Dynamic Tool Discovery +- **list_workflow_jobs** - List workflow jobs + - `filter`: Filters jobs by their completed_at timestamp (string, optional) + - `owner`: Repository owner (string, required) + - `page`: Page number for pagination (min 1) (number, optional) + - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) + - `repo`: Repository name (string, required) + - `run_id`: The unique identifier of the workflow run (number, required) -**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. +- **list_workflow_run_artifacts** - List workflow artifacts + - `owner`: Repository owner (string, required) + - `page`: Page number for pagination (min 1) (number, optional) + - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) + - `repo`: Repository name (string, required) + - `run_id`: The unique identifier of the workflow run (number, required) -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. +- **list_workflow_runs** - List workflow runs + - `actor`: Returns someone's workflow runs. Use the login for the user who created the workflow run. (string, optional) + - `branch`: Returns workflow runs associated with a branch. Use the name of the branch. (string, optional) + - `event`: Returns workflow runs for a specific event type (string, optional) + - `owner`: Repository owner (string, required) + - `page`: Page number for pagination (min 1) (number, optional) + - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) + - `repo`: Repository name (string, required) + - `status`: Returns workflow runs with the check run status (string, optional) + - `workflow_id`: The workflow ID or workflow file name (string, required) -### Using Dynamic Tool Discovery +- **list_workflows** - List workflows + - `owner`: Repository owner (string, required) + - `page`: Page number for pagination (min 1) (number, optional) + - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) + - `repo`: Repository name (string, required) -When using the binary, you can pass the `--dynamic-toolsets` flag. +- **rerun_failed_jobs** - Rerun failed jobs + - `owner`: Repository owner (string, required) + - `repo`: Repository name (string, required) + - `run_id`: The unique identifier of the workflow run (number, required) -```bash -./github-mcp-server --dynamic-toolsets -``` +- **rerun_workflow_run** - Rerun workflow run + - `owner`: Repository owner (string, required) + - `repo`: Repository name (string, required) + - `run_id`: The unique identifier of the workflow run (number, required) -When using Docker, you can pass the toolsets as environment variables: +- **run_workflow** - Run workflow + - `inputs`: Inputs the workflow accepts (object, optional) + - `owner`: Repository owner (string, required) + - `ref`: The git reference for the workflow. The reference can be a branch or tag name. (string, required) + - `repo`: Repository name (string, required) + - `workflow_id`: The workflow ID (numeric) or workflow file name (e.g., main.yml, ci.yaml) (string, required) -```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. +Code Security -```bash -./github-mcp-server --read-only -``` +- **get_code_scanning_alert** - Get code scanning alert + - `alertNumber`: The number of the alert. (number, required) + - `owner`: The owner of the repository. (string, required) + - `repo`: The name of the repository. (string, required) -When using Docker, you can pass the read-only mode as an environment variable: +- **list_code_scanning_alerts** - List code scanning alerts + - `owner`: The owner of the repository. (string, required) + - `ref`: The Git reference for the results you want to list. (string, optional) + - `repo`: The name of the repository. (string, required) + - `severity`: Filter code scanning alerts by severity (string, optional) + - `state`: Filter code scanning alerts by state. Defaults to open (string, optional) + - `tool_name`: The name of the tool used for code scanning. (string, optional) -```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. +Context -- 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://" - } -} -``` +- **get_me** - Get my user profile + - No parameters required -## 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: +Dependabot -```json -{ - "TOOL_ADD_ISSUE_COMMENT_DESCRIPTION": "an alternative description", - "TOOL_CREATE_BRANCH_DESCRIPTION": "Create a new branch in a GitHub repository" -} -``` +- **get_dependabot_alert** - Get dependabot alert + - `alertNumber`: The number of the alert. (number, required) + - `owner`: The owner of the repository. (string, required) + - `repo`: The name of the repository. (string, required) -You can create an export of the current translations by running the binary with -the `--export-translations` flag. +- **list_dependabot_alerts** - List dependabot alerts + - `owner`: The owner of the repository. (string, required) + - `repo`: The name of the repository. (string, required) + - `severity`: Filter dependabot alerts by severity (string, optional) + - `state`: Filter dependabot alerts by state. Defaults to open (string, optional) -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. +Discussions -For example, to override the `TOOL_ADD_ISSUE_COMMENT_DESCRIPTION` tool, you can -set the following environment variable: +- **get_discussion** - Get discussion + - `discussionNumber`: Discussion Number (number, required) + - `owner`: Repository owner (string, required) + - `repo`: Repository name (string, required) -```sh -export GITHUB_MCP_TOOL_ADD_ISSUE_COMMENT_DESCRIPTION="an alternative description" -``` +- **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) -## Tools +- **list_discussion_categories** - List discussion categories + - `owner`: Repository owner (string, required) + - `repo`: Repository name (string, required) -### Users +- **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) -- **get_me** - Get details of the authenticated user - - No parameters required +
-### Issues +
-- **get_issue** - Gets the contents of an issue within a repository +Issues +- **add_issue_comment** - Add comment to issue + - `body`: Comment content (string, required) + - `issue_number`: Issue number to comment on (number, required) - `owner`: Repository owner (string, required) - `repo`: Repository name (string, required) - - `issue_number`: Issue number (number, required) - -- **get_issue_comments** - Get comments for a GitHub issue +- **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) - - `issue_number`: Issue number (number, required) - -- **create_issue** - Create a new issue in a GitHub repository + - `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) - `repo`: Repository name (string, required) - - `title`: Issue title (string, required) - - `body`: Issue body content (string, optional) + +- **create_issue** - Open new issue - `assignees`: Usernames to assign to this issue (string[], optional) + - `body`: Issue body content (string, optional) - `labels`: Labels to apply to this issue (string[], optional) + - `milestone`: Milestone number (number, optional) + - `owner`: Repository owner (string, required) + - `repo`: Repository name (string, required) + - `title`: Issue title (string, required) -- **add_issue_comment** - Add a comment to an issue +- **get_issue** - Get issue details + - `issue_number`: The number of the issue (number, required) + - `owner`: The owner of the repository (string, required) + - `repo`: The name of the repository (string, required) +- **get_issue_comments** - Get issue comments + - `issue_number`: Issue number (number, required) - `owner`: Repository owner (string, required) + - `page`: Page number for pagination (min 1) (number, optional) + - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) - `repo`: Repository name (string, required) - - `issue_number`: Issue number (number, required) - - `body`: Comment text (string, required) - -- **list_issues** - List and filter repository issues +- **list_issues** - List issues + - `direction`: Sort direction (string, optional) + - `labels`: Filter by labels (string[], optional) - `owner`: Repository owner (string, required) + - `page`: Page number for pagination (min 1) (number, optional) + - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) - `repo`: Repository name (string, required) - - `state`: Filter by state ('open', 'closed', 'all') (string, optional) - - `labels`: Labels to filter by (string[], optional) - - `sort`: Sort by ('created', 'updated', 'comments') (string, optional) - - `direction`: Sort direction ('asc', 'desc') (string, optional) - `since`: Filter by date (ISO 8601 timestamp) (string, optional) - - `page`: Page number (number, optional) - - `perPage`: Results per page (number, optional) + - `sort`: Sort order (string, optional) + - `state`: Filter by state (string, optional) -- **update_issue** - Update an existing issue in a GitHub repository +- **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) - - `issue_number`: Issue number to update (number, required) - - `title`: New title (string, optional) - - `body`: New description (string, optional) - - `state`: New state ('open' or 'closed') (string, optional) - - `labels`: New labels (string[], optional) - - `assignees`: New assignees (string[], optional) - - `milestone`: New milestone number (number, optional) + - `sub_issue_id`: The ID of the sub-issue to remove. ID is not the same as issue number (number, required) -- **search_issues** - Search for issues and pull requests - - `query`: Search query (string, required) - - `sort`: Sort field (string, optional) - - `order`: Sort order (string, optional) - - `page`: Page number (number, optional) - - `perPage`: Results per page (number, optional) - -- **assign_copilot_to_issue** - Assign Copilot to a specific issue in a GitHub repository +- **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) + - `page`: Page number for pagination (min 1) (number, optional) + - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) + - `query`: Search query using GitHub issues search syntax (string, required) + - `repo`: Optional repository name. If provided with owner, only notifications for this repository are listed. (string, optional) + - `sort`: Sort field by number of matches of categories, defaults to best match (string, optional) + +- **update_issue** - Edit issue + - `assignees`: New assignees (string[], optional) + - `body`: New description (string, optional) + - `issue_number`: Issue number to update (number, required) + - `labels`: New labels (string[], optional) + - `milestone`: New milestone number (number, optional) - `owner`: Repository owner (string, required) - `repo`: Repository name (string, required) - - `issueNumber`: Issue number (number, required) - - _Note_: This tool can help with creating a Pull Request with source code changes to resolve the issue. More information can be found at [GitHub Copilot documentation](https://docs.github.com/en/copilot/using-github-copilot/using-copilot-coding-agent-to-work-on-tasks/about-assigning-tasks-to-copilot) + - `state`: New state (string, optional) + - `title`: New title (string, optional) +
-### Pull Requests +
-- **get_pull_request** - Get details of a specific pull request +Notifications - - `owner`: Repository owner (string, required) - - `repo`: Repository name (string, required) - - `pullNumber`: Pull request number (number, required) +- **dismiss_notification** - Dismiss notification + - `state`: The new state of the notification (read/done) (string, optional) + - `threadID`: The ID of the notification thread (string, required) -- **list_pull_requests** - List and filter repository pull requests +- **get_notification_details** - Get notification details + - `notificationID`: The ID of the notification (string, required) - - `owner`: Repository owner (string, required) - - `repo`: Repository name (string, required) - - `state`: PR state (string, optional) - - `sort`: Sort field (string, optional) - - `direction`: Sort direction (string, optional) - - `perPage`: Results per page (number, optional) - - `page`: Page number (number, optional) +- **list_notifications** - List notifications + - `before`: Only show notifications updated before the given time (ISO 8601 format) (string, optional) + - `filter`: Filter notifications to, use default unless specified. Read notifications are ones that have already been acknowledged by the user. Participating notifications are those that the user is directly involved in, such as issues or pull requests they have commented on or created. (string, optional) + - `owner`: Optional repository owner. If provided with repo, only notifications for this repository are listed. (string, optional) + - `page`: Page number for pagination (min 1) (number, optional) + - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) + - `repo`: Optional repository name. If provided with owner, only notifications for this repository are listed. (string, optional) + - `since`: Only show notifications updated after the given time (ISO 8601 format) (string, optional) -- **merge_pull_request** - Merge a pull request +- **manage_notification_subscription** - Manage notification subscription + - `action`: Action to perform: ignore, watch, or delete the notification subscription. (string, required) + - `notificationID`: The ID of the notification thread. (string, required) - - `owner`: Repository owner (string, required) - - `repo`: Repository name (string, required) - - `pullNumber`: Pull request number (number, required) - - `commit_title`: Title for the merge commit (string, optional) - - `commit_message`: Message for the merge commit (string, optional) - - `merge_method`: Merge method (string, optional) +- **manage_repository_notification_subscription** - Manage repository notification subscription + - `action`: Action to perform: ignore, watch, or delete the repository notification subscription. (string, required) + - `owner`: The account owner of the repository. (string, required) + - `repo`: The name of the repository. (string, required) -- **get_pull_request_files** - Get the list of files changed in a pull request +- **mark_all_notifications_read** - Mark all notifications as read + - `lastReadAt`: Describes the last point that notifications were checked (optional). Default: Now (string, optional) + - `owner`: Optional repository owner. If provided with repo, only notifications for this repository are marked as read. (string, optional) + - `repo`: Optional repository name. If provided with owner, only notifications for this repository are marked as read. (string, optional) - - `owner`: Repository owner (string, required) - - `repo`: Repository name (string, required) - - `pullNumber`: Pull request number (number, required) +
-- **get_pull_request_status** - Get the combined status of all status checks for a pull request +
- - `owner`: Repository owner (string, required) - - `repo`: Repository name (string, required) - - `pullNumber`: Pull request number (number, required) +Organizations -- **update_pull_request_branch** - Update a pull request branch with the latest changes from the base branch +- **search_orgs** - Search organizations + - `order`: Sort order (string, optional) + - `page`: Page number for pagination (min 1) (number, optional) + - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) + - `query`: Search query using GitHub organizations search syntax scoped to type:org (string, required) + - `sort`: Sort field by category (string, optional) - - `owner`: Repository owner (string, required) - - `repo`: Repository name (string, required) - - `pullNumber`: Pull request number (number, required) - - `expectedHeadSha`: The expected SHA of the pull request's HEAD ref (string, optional) +
-- **get_pull_request_comments** - Get the review comments on a pull request +
+Pull Requests + +- **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) - - `repo`: Repository name (string, required) + - `path`: The relative path to the file that necessitates a comment (string, required) - `pullNumber`: Pull request number (number, required) + - `repo`: Repository name (string, required) + - `side`: The side of the diff to comment on. LEFT indicates the previous state, RIGHT indicates the new state (string, optional) + - `startLine`: For multi-line comments, the first line of the range that the comment applies to (number, optional) + - `startSide`: For multi-line comments, the starting side of the diff that the comment applies to. LEFT indicates the previous state, RIGHT indicates the new state (string, optional) + - `subjectType`: The level at which the comment is targeted (string, required) -- **get_pull_request_reviews** - Get the reviews on a pull request - +- **create_and_submit_pull_request_review** - Create and submit a pull request review without comments + - `body`: Review comment text (string, required) + - `commitID`: SHA of commit to review (string, optional) + - `event`: Review action to perform (string, required) - `owner`: Repository owner (string, required) - - `repo`: Repository name (string, required) - `pullNumber`: Pull request number (number, required) + - `repo`: Repository name (string, required) -- **get_pull_request_diff** - Get the diff of a pull request - +- **create_pending_pull_request_review** - Create pending pull request review + - `commitID`: SHA of commit to review (string, optional) - `owner`: Repository owner (string, required) - - `repo`: Repository name (string, required) - `pullNumber`: Pull request number (number, required) + - `repo`: Repository name (string, required) -- **create_pending_pull_request_review** - Create a pending review for a pull request that can be submitted later - +- **create_pull_request** - Open new pull request + - `base`: Branch to merge into (string, required) + - `body`: PR description (string, optional) + - `draft`: Create as draft PR (boolean, optional) + - `head`: Branch containing changes (string, required) + - `maintainer_can_modify`: Allow maintainer edits (boolean, optional) - `owner`: Repository owner (string, required) - `repo`: Repository name (string, required) - - `pullNumber`: Pull request number (number, required) - - `commitID`: SHA of commit to review (string, optional) - -- **add_pull_request_review_comment_to_pending_review** - Add a comment to the requester's latest pending pull request review + - `title`: PR title (string, required) +- **delete_pending_pull_request_review** - Delete the requester's latest pending pull request review - `owner`: Repository owner (string, required) - - `repo`: Repository name (string, required) - `pullNumber`: Pull request number (number, required) - - `path`: The relative path to the file that necessitates a comment (string, required) - - `body`: The text of the review comment (string, required) - - `subjectType`: The level at which the comment is targeted (string, required) - - Enum: "FILE", "LINE" - - `line`: The line of the blob in the pull request diff that the comment applies to (number, optional) - - `side`: The side of the diff to comment on (string, optional) - - Enum: "LEFT", "RIGHT" - - `startLine`: For multi-line comments, the first line of the range (number, optional) - - `startSide`: For multi-line comments, the starting side of the diff (string, optional) - - Enum: "LEFT", "RIGHT" - -- **submit_pending_pull_request_review** - Submit the requester's latest pending pull request review + - `repo`: Repository name (string, required) +- **get_pull_request** - Get pull request details - `owner`: Repository owner (string, required) - - `repo`: Repository name (string, required) - `pullNumber`: Pull request number (number, required) - - `event`: The event to perform (string, required) - - Enum: "APPROVE", "REQUEST_CHANGES", "COMMENT" - - `body`: The text of the review comment (string, optional) - -- **delete_pending_pull_request_review** - Delete the requester's latest pending pull request review + - `repo`: Repository name (string, required) +- **get_pull_request_comments** - Get pull request comments - `owner`: Repository owner (string, required) - - `repo`: Repository name (string, required) - `pullNumber`: Pull request number (number, required) + - `repo`: Repository name (string, required) -- **create_and_submit_pull_request_review** - Create and submit a review for a pull request without review comments - +- **get_pull_request_diff** - Get pull request diff - `owner`: Repository owner (string, required) - - `repo`: Repository name (string, required) - `pullNumber`: Pull request number (number, required) - - `body`: Review comment text (string, required) - - `event`: Review action ('APPROVE', 'REQUEST_CHANGES', 'COMMENT') (string, required) - - `commitID`: SHA of commit to review (string, optional) - -- **create_pull_request** - Create a new pull request + - `repo`: Repository name (string, required) +- **get_pull_request_files** - Get pull request files - `owner`: Repository owner (string, required) + - `page`: Page number for pagination (min 1) (number, optional) + - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) + - `pullNumber`: Pull request number (number, required) - `repo`: Repository name (string, required) - - `title`: PR title (string, required) - - `body`: PR description (string, optional) - - `head`: Branch containing changes (string, required) - - `base`: Branch to merge into (string, required) - - `draft`: Create as draft PR (boolean, optional) - - `maintainer_can_modify`: Allow maintainer edits (boolean, optional) -- **update_pull_request** - Update an existing pull request in a GitHub repository +- **get_pull_request_reviews** - Get pull request reviews + - `owner`: Repository owner (string, required) + - `pullNumber`: Pull request number (number, required) + - `repo`: Repository name (string, required) +- **get_pull_request_status** - Get pull request status checks - `owner`: Repository owner (string, required) + - `pullNumber`: Pull request number (number, required) - `repo`: Repository name (string, required) - - `pullNumber`: Pull request number to update (number, required) - - `title`: New title (string, optional) - - `body`: New description (string, optional) - - `state`: New state ('open' or 'closed') (string, optional) - - `base`: New base branch name (string, optional) - - `maintainer_can_modify`: Allow maintainer edits (boolean, optional) -- **request_copilot_review** - Request a GitHub Copilot review for a pull request (experimental; subject to GitHub API support) +- **list_pull_requests** - List pull requests + - `base`: Filter by base branch (string, optional) + - `direction`: Sort direction (string, optional) + - `head`: Filter by head user/org and branch (string, optional) + - `owner`: Repository owner (string, required) + - `page`: Page number for pagination (min 1) (number, optional) + - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) + - `repo`: Repository name (string, required) + - `sort`: Sort by (string, optional) + - `state`: Filter by state (string, optional) +- **merge_pull_request** - Merge pull request + - `commit_message`: Extra detail for merge commit (string, optional) + - `commit_title`: Title for merge commit (string, optional) + - `merge_method`: Merge method (string, optional) - `owner`: Repository owner (string, required) + - `pullNumber`: Pull request number (number, required) - `repo`: Repository name (string, required) + +- **request_copilot_review** - Request Copilot review + - `owner`: Repository owner (string, required) - `pullNumber`: Pull request number (number, required) - - _Note_: Currently, this tool will only work for github.com + - `repo`: Repository name (string, required) -### Repositories +- **search_pull_requests** - Search pull requests + - `order`: Sort order (string, optional) + - `owner`: Optional repository owner. If provided with repo, only notifications for this repository are listed. (string, optional) + - `page`: Page number for pagination (min 1) (number, optional) + - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) + - `query`: Search query using GitHub pull request search syntax (string, required) + - `repo`: Optional repository name. If provided with owner, only notifications for this repository are listed. (string, optional) + - `sort`: Sort field by number of matches of categories, defaults to best match (string, optional) -- **create_or_update_file** - Create or update a single file in a repository +- **submit_pending_pull_request_review** - Submit the requester's latest pending pull request review + - `body`: The text of the review comment (string, optional) + - `event`: The event to perform (string, required) - `owner`: Repository owner (string, required) + - `pullNumber`: Pull request number (number, required) - `repo`: Repository name (string, required) - - `path`: File path (string, required) - - `message`: Commit message (string, required) - - `content`: File content (string, required) - - `branch`: Branch name (string, optional) - - `sha`: File SHA if updating (string, optional) -- **delete_file** - Delete a file from a GitHub repository +- **update_pull_request** - Edit pull request + - `base`: New base branch name (string, optional) + - `body`: New description (string, optional) + - `maintainer_can_modify`: Allow maintainer edits (boolean, optional) - `owner`: Repository owner (string, required) + - `pullNumber`: Pull request number to update (number, required) - `repo`: Repository name (string, required) - - `path`: Path to the file to delete (string, required) - - `message`: Commit message (string, required) - - `branch`: Branch to delete the file from (string, required) + - `state`: New state (string, optional) + - `title`: New title (string, optional) -- **list_branches** - List branches in a GitHub repository +- **update_pull_request_branch** - Update pull request branch + - `expectedHeadSha`: The expected SHA of the pull request's HEAD ref (string, optional) - `owner`: Repository owner (string, required) + - `pullNumber`: Pull request number (number, required) - `repo`: Repository name (string, required) - - `page`: Page number (number, optional) - - `perPage`: Results per page (number, optional) -- **push_files** - Push multiple files in a single commit +
+ +
+ +Repositories + +- **create_branch** - Create branch + - `branch`: Name for new branch (string, required) + - `from_branch`: Source branch (defaults to repo default) (string, optional) - `owner`: Repository owner (string, required) - `repo`: Repository name (string, required) - - `branch`: Branch to push to (string, required) - - `files`: Files to push, each with path and content (array, required) - - `message`: Commit message (string, required) -- **search_repositories** - Search for GitHub repositories - - `query`: Search query (string, required) - - `sort`: Sort field (string, optional) - - `order`: Sort order (string, optional) - - `page`: Page number (number, optional) - - `perPage`: Results per page (number, optional) +- **create_or_update_file** - Create or update file + - `branch`: Branch to create/update the file in (string, required) + - `content`: Content of the file (string, required) + - `message`: Commit message (string, required) + - `owner`: Repository owner (username or organization) (string, required) + - `path`: Path where to create/update the file (string, required) + - `repo`: Repository name (string, required) + - `sha`: Required if updating an existing file. The blob SHA of the file being replaced. (string, optional) -- **create_repository** - Create a new GitHub repository - - `name`: Repository name (string, required) +- **create_repository** - Create repository + - `autoInit`: Initialize with README (boolean, optional) - `description`: Repository description (string, optional) - - `private`: Whether the repository is private (boolean, optional) - - `autoInit`: Auto-initialize with README (boolean, optional) + - `name`: Repository name (string, required) + - `private`: Whether repo should be private (boolean, optional) + +- **delete_file** - Delete file + - `branch`: Branch to delete the file from (string, required) + - `message`: Commit message (string, required) + - `owner`: Repository owner (username or organization) (string, required) + - `path`: Path to the file to delete (string, required) + - `repo`: Repository name (string, required) -- **get_file_contents** - Get contents of a file or directory +- **fork_repository** - Fork repository + - `organization`: Organization to fork to (string, optional) - `owner`: Repository owner (string, required) - `repo`: Repository name (string, required) - - `path`: File path (string, required) - - `ref`: Git reference (string, optional) -- **fork_repository** - Fork a repository +- **get_commit** - Get commit details - `owner`: Repository owner (string, required) + - `page`: Page number for pagination (min 1) (number, optional) + - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) + - `repo`: Repository name (string, required) + - `sha`: Commit SHA, branch name, or tag name (string, required) + +- **get_file_contents** - Get file or directory contents + - `owner`: Repository owner (username or organization) (string, required) + - `path`: Path to file/directory (directories must end with a slash '/') (string, optional) + - `ref`: Accepts optional git refs such as `refs/tags/{tag}`, `refs/heads/{branch}` or `refs/pull/{pr_number}/head` (string, optional) - `repo`: Repository name (string, required) - - `organization`: Target organization name (string, optional) + - `sha`: Accepts optional commit SHA. If specified, it will be used instead of ref (string, optional) -- **create_branch** - Create a new branch +- **get_tag** - Get tag details - `owner`: Repository owner (string, required) - `repo`: Repository name (string, required) - - `branch`: New branch name (string, required) - - `sha`: SHA to create branch from (string, required) + - `tag`: Tag name (string, required) -- **list_commits** - Get a list of commits of a branch in a repository +- **list_branches** - List branches - `owner`: Repository owner (string, required) + - `page`: Page number for pagination (min 1) (number, optional) + - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) - `repo`: Repository name (string, required) - - `sha`: Branch name, tag, or commit SHA (string, optional) - - `author`: Author username or email address (string, optional) - - `path`: Only commits containing this file path (string, optional) - - `page`: Page number (number, optional) - - `perPage`: Results per page (number, optional) -- **get_commit** - Get details for a commit from a repository +- **list_commits** - List commits + - `author`: Author username or email address to filter commits by (string, optional) - `owner`: Repository owner (string, required) + - `page`: Page number for pagination (min 1) (number, optional) + - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) - `repo`: Repository name (string, required) - - `sha`: Commit SHA, branch name, or tag name (string, required) - - `page`: Page number, for files in the commit (number, optional) - - `perPage`: Results per page, for files in the commit (number, optional) + - `sha`: Commit SHA, branch or tag name to list commits of. If not provided, uses the default branch of the repository. If a commit SHA is provided, will list commits up to that SHA. (string, optional) -- **get_tag** - Get details about a specific git tag in a GitHub repository +- **list_tags** - List tags - `owner`: Repository owner (string, required) + - `page`: Page number for pagination (min 1) (number, optional) + - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) - `repo`: Repository name (string, required) - - `tag`: Tag name (string, required) -- **list_tags** - List git tags in a GitHub repository +- **push_files** - Push files to repository + - `branch`: Branch to push to (string, required) + - `files`: Array of file objects to push, each object with path (string) and content (string) (object[], required) + - `message`: Commit message (string, required) - `owner`: Repository owner (string, required) - `repo`: Repository name (string, required) - - `page`: Page number (number, optional) - - `perPage`: Results per page (number, optional) -- **search_code** - Search for code across GitHub repositories - - `query`: Search query (string, required) - - `sort`: Sort field (string, optional) +- **search_code** - Search code - `order`: Sort order (string, optional) - - `page`: Page number (number, optional) - - `perPage`: Results per page (number, optional) + - `page`: Page number for pagination (min 1) (number, optional) + - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) + - `query`: Search query using GitHub code search syntax (string, required) + - `sort`: Sort field ('indexed' only) (string, optional) + +- **search_repositories** - Search repositories + - `page`: Page number for pagination (min 1) (number, optional) + - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) + - `query`: Search query (string, required) + +
+ +
+ +Secret Protection + +- **get_secret_scanning_alert** - Get secret scanning alert + - `alertNumber`: The number of the alert. (number, required) + - `owner`: The owner of the repository. (string, required) + - `repo`: The name of the repository. (string, required) + +- **list_secret_scanning_alerts** - List secret scanning alerts + - `owner`: The owner of the repository. (string, required) + - `repo`: The name of the repository. (string, required) + - `resolution`: Filter by resolution (string, optional) + - `secret_type`: A comma-separated list of secret types to return. All default secret patterns are returned. To return generic patterns, pass the token name(s) in the parameter. (string, optional) + - `state`: Filter by state (string, optional) -### Users +
-- **search_users** - Search for GitHub users - - `q`: Search query (string, required) - - `sort`: Sort field (string, optional) +
+ +Users + +- **search_users** - Search users - `order`: Sort order (string, optional) - - `page`: Page number (number, optional) - - `perPage`: Results per page (number, optional) + - `page`: Page number for pagination (min 1) (number, optional) + - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) + - `query`: Search query using GitHub users search syntax scoped to type:user (string, required) + - `sort`: Sort field by category (string, optional) -### Actions +
+ -- **list_workflows** - List workflows in a repository +### Additional Tools in Remote Github MCP Server - - `owner`: Repository owner (string, required) - - `repo`: Repository name (string, required) - - `page`: Page number (number, optional) - - `perPage`: Results per page (number, optional) +
-- **list_workflow_runs** - List workflow runs for a specific workflow +Copilot coding agent - - `owner`: Repository owner (string, required) - - `repo`: Repository name (string, required) - - `workflow_id`: Workflow ID or filename (string, required) - - `branch`: Filter by branch name (string, optional) - - `event`: Filter by event type (string, optional) - - `status`: Filter by run status (string, optional) - - `page`: Page number (number, optional) - - `perPage`: Results per page (number, optional) +- **create_pull_request_with_copilot** - Perform task with GitHub Copilot coding agent + - `owner`: Repository owner. You can guess the owner, but confirm it with the user before proceeding. (string, required) + - `repo`: Repository name. You can guess the repository name, but confirm it with the user before proceeding. (string, required) + - `problem_statement`: Detailed description of the task to be performed (e.g., 'Implement a feature that does X', 'Fix bug Y', etc.) (string, required) + - `title`: Title for the pull request that will be created (string, required) + - `base_ref`: Git reference (e.g., branch) that the agent will start its work from. If not specified, defaults to the repository's default branch (string, optional) -- **run_workflow** - Trigger a workflow via workflow_dispatch event +
- - `owner`: Repository owner (string, required) - - `repo`: Repository name (string, required) - - `workflow_id`: Workflow ID or filename (string, required) - - `ref`: Git reference (branch, tag, or SHA) (string, required) - - `inputs`: Input parameters for the workflow (object, optional) +#### Specifying Toolsets -- **get_workflow_run** - Get details of a specific workflow run +To specify toolsets you want available to the LLM, you can pass an allow-list in two ways: - - `owner`: Repository owner (string, required) - - `repo`: Repository name (string, required) - - `run_id`: Workflow run ID (number, required) +1. **Using Command Line Argument**: -- **get_workflow_run_logs** - Download logs for a workflow run + ```bash + github-mcp-server --toolsets repos,issues,pull_requests,actions,code_security + ``` - - `owner`: Repository owner (string, required) - - `repo`: Repository name (string, required) - - `run_id`: Workflow run ID (number, required) +2. **Using Environment Variable**: + ```bash + GITHUB_TOOLSETS="repos,issues,pull_requests,actions,code_security" ./github-mcp-server + ``` -- **list_workflow_jobs** - List jobs for a workflow run +The environment variable `GITHUB_TOOLSETS` takes precedence over the command line argument if both are provided. - - `owner`: Repository owner (string, required) - - `repo`: Repository name (string, required) - - `run_id`: Workflow run ID (number, required) - - `filter`: Filter by job status (string, optional) - - `page`: Page number (number, optional) - - `perPage`: Results per page (number, optional) +### Using Toolsets With Docker -- **get_job_logs** - Download logs for a specific workflow job or efficiently get all failed job logs for a workflow run +When using Docker, you can pass the toolsets as environment variables: - - `owner`: Repository owner (string, required) - - `repo`: Repository name (string, required) - - `job_id`: Job ID (number, required for single job logs) - - `run_id`: Workflow run ID (number, required when using failed_only) - - `failed_only`: When true, gets logs for all failed jobs in run_id (boolean, optional) - - `return_content`: Returns actual log content instead of URLs (boolean, optional) +```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 +``` -- **rerun_workflow_run** - Re-run an entire workflow +### The "all" Toolset - - `owner`: Repository owner (string, required) - - `repo`: Repository name (string, required) - - `run_id`: Workflow run ID (number, required) - - `enable_debug_logging`: Enable debug logging for the re-run (boolean, optional) +The special toolset `all` can be provided to enable all available toolsets regardless of any other configuration: -- **rerun_failed_jobs** - Re-run only the failed jobs in a workflow run +```bash +./github-mcp-server --toolsets all +``` - - `owner`: Repository owner (string, required) - - `repo`: Repository name (string, required) - - `run_id`: Workflow run ID (number, required) - - `enable_debug_logging`: Enable debug logging for the re-run (boolean, optional) +Or using the environment variable: -- **cancel_workflow_run** - Cancel a running workflow +```bash +GITHUB_TOOLSETS="all" ./github-mcp-server +``` - - `owner`: Repository owner (string, required) - - `repo`: Repository name (string, required) - - `run_id`: Workflow run ID (number, required) +## Dynamic Tool Discovery -- **list_workflow_run_artifacts** - List artifacts from a workflow run +**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. - - `owner`: Repository owner (string, required) - - `repo`: Repository name (string, required) - - `run_id`: Workflow run ID (number, required) - - `page`: Page number (number, optional) - - `perPage`: Results per page (number, optional) +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. -- **download_workflow_run_artifact** - Get download URL for a specific artifact +### Using Dynamic Tool Discovery - - `owner`: Repository owner (string, required) - - `repo`: Repository name (string, required) - - `artifact_id`: Artifact ID (number, required) +When using the binary, you can pass the `--dynamic-toolsets` flag. -- **delete_workflow_run_logs** - Delete logs for a workflow run +```bash +./github-mcp-server --dynamic-toolsets +``` - - `owner`: Repository owner (string, required) - - `repo`: Repository name (string, required) - - `run_id`: Workflow run ID (number, required) +When using Docker, you can pass the toolsets as environment variables: -- **get_workflow_run_usage** - Get usage metrics for a workflow run +```bash +docker run -i --rm \ + -e GITHUB_PERSONAL_ACCESS_TOKEN= \ + -e GITHUB_DYNAMIC_TOOLSETS=1 \ + ghcr.io/github/github-mcp-server +``` - - `owner`: Repository owner (string, required) - - `repo`: Repository name (string, required) - - `run_id`: Workflow run ID (number, required) +## Read-Only Mode -### Code Scanning +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. -- **get_code_scanning_alert** - Get a code scanning alert +```bash +./github-mcp-server --read-only +``` - - `owner`: Repository owner (string, required) - - `repo`: Repository name (string, required) - - `alertNumber`: Alert number (number, required) +When using Docker, you can pass the read-only mode as an environment variable: -- **list_code_scanning_alerts** - List code scanning alerts for a repository - - `owner`: Repository owner (string, required) - - `repo`: Repository name (string, required) - - `ref`: Git reference (string, optional) - - `state`: Alert state (string, optional) - - `severity`: Alert severity (string, optional) - - `tool_name`: The name of the tool used for code scanning (string, optional) +```bash +docker run -i --rm \ + -e GITHUB_PERSONAL_ACCESS_TOKEN= \ + -e GITHUB_READ_ONLY=1 \ + ghcr.io/github/github-mcp-server +``` -### Secret Scanning +## GitHub Enterprise Server and Enterprise Cloud with data residency (ghe.com) -- **get_secret_scanning_alert** - Get a secret scanning alert +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. - - `owner`: Repository owner (string, required) - - `repo`: Repository name (string, required) - - `alertNumber`: Alert number (number, required) +- 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://" + } +} +``` -- **list_secret_scanning_alerts** - List secret scanning alerts for a repository - - `owner`: Repository owner (string, required) - - `repo`: Repository name (string, required) - - `state`: Alert state (string, optional) - - `secret_type`: The secret types to be filtered for in a comma-separated list (string, optional) - - `resolution`: The resolution status (string, optional) +## i18n / Overriding Descriptions -### Notifications +The descriptions of the tools can be overridden by creating a +`github-mcp-server-config.json` file in the same directory as the binary. -- **list_notifications** – List notifications for a GitHub user - - `filter`: Filter to apply to the response (`default`, `include_read_notifications`, `only_participating`) - - `since`: Only show notifications updated after the given time (ISO 8601 format) - - `before`: Only show notifications updated before the given time (ISO 8601 format) - - `owner`: Optional repository owner (string) - - `repo`: Optional repository name (string) - - `page`: Page number (number, optional) - - `perPage`: Results per page (number, optional) +The file should contain a JSON object with the tool names as keys and the new +descriptions as values. For example: -- **get_notification_details** – Get detailed information for a specific GitHub notification - - `notificationID`: The ID of the notification (string, required) +```json +{ + "TOOL_ADD_ISSUE_COMMENT_DESCRIPTION": "an alternative description", + "TOOL_CREATE_BRANCH_DESCRIPTION": "Create a new branch in a GitHub repository" +} +``` -- **dismiss_notification** – Dismiss a notification by marking it as read or done - - `threadID`: The ID of the notification thread (string, required) - - `state`: The new state of the notification (`read` or `done`) +You can create an export of the current translations by running the binary with +the `--export-translations` flag. -- **mark_all_notifications_read** – Mark all notifications as read - - `lastReadAt`: Describes the last point that notifications were checked (optional, RFC3339/ISO8601 string, default: now) - - `owner`: Optional repository owner (string) - - `repo`: Optional repository name (string) +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. -- **manage_notification_subscription** – Manage a notification subscription (ignore, watch, or delete) for a notification thread - - `notificationID`: The ID of the notification thread (string, required) - - `action`: Action to perform: `ignore`, `watch`, or `delete` (string, required) +```sh +./github-mcp-server --export-translations +cat github-mcp-server-config.json +``` -- **manage_repository_notification_subscription** – Manage a repository notification subscription (ignore, watch, or delete) - - `owner`: The account owner of the repository (string, required) - - `repo`: The name of the repository (string, required) - - `action`: Action to perform: `ignore`, `watch`, or `delete` (string, required) - -## Resources - -### Repository Content - -- **Get Repository Content** - Retrieves the content of a repository at a specific path. - - - **Template**: `repo://{owner}/{repo}/contents{/path*}` - - **Parameters**: - - `owner`: Repository owner (string, required) - - `repo`: Repository name (string, required) - - `path`: File or directory path (string, optional) - -- **Get Repository Content for a Specific Branch** - Retrieves the content of a repository at a specific path for a given branch. - - - **Template**: `repo://{owner}/{repo}/refs/heads/{branch}/contents{/path*}` - - **Parameters**: - - `owner`: Repository owner (string, required) - - `repo`: Repository name (string, required) - - `branch`: Branch name (string, required) - - `path`: File or directory path (string, optional) - -- **Get Repository Content for a Specific Commit** - Retrieves the content of a repository at a specific path for a given commit. - - - **Template**: `repo://{owner}/{repo}/sha/{sha}/contents{/path*}` - - **Parameters**: - - `owner`: Repository owner (string, required) - - `repo`: Repository name (string, required) - - `sha`: Commit SHA (string, required) - - `path`: File or directory path (string, optional) - -- **Get Repository Content for a Specific Tag** - Retrieves the content of a repository at a specific path for a given tag. - - - **Template**: `repo://{owner}/{repo}/refs/tags/{tag}/contents{/path*}` - - **Parameters**: - - `owner`: Repository owner (string, required) - - `repo`: Repository name (string, required) - - `tag`: Tag name (string, required) - - `path`: File or directory path (string, optional) - -- **Get Repository Content for a Specific Pull Request** - Retrieves the content of a repository at a specific path for a given pull request. - - - **Template**: `repo://{owner}/{repo}/refs/pull/{prNumber}/head/contents{/path*}` - - **Parameters**: - - `owner`: Repository owner (string, required) - - `repo`: Repository name (string, required) - - `prNumber`: Pull request number (string, required) - - `path`: File or directory path (string, optional) +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 diff --git a/cmd/github-mcp-server/generate_docs.go b/cmd/github-mcp-server/generate_docs.go new file mode 100644 index 000000000..983ed4398 --- /dev/null +++ b/cmd/github-mcp-server/generate_docs.go @@ -0,0 +1,354 @@ +package main + +import ( + "context" + "fmt" + "net/url" + "os" + "regexp" + "sort" + "strings" + + "github.com/github/github-mcp-server/pkg/github" + "github.com/github/github-mcp-server/pkg/raw" + "github.com/github/github-mcp-server/pkg/toolsets" + "github.com/github/github-mcp-server/pkg/translations" + gogithub "github.com/google/go-github/v73/github" + "github.com/mark3labs/mcp-go/mcp" + "github.com/shurcooL/githubv4" + "github.com/spf13/cobra" +) + +var generateDocsCmd = &cobra.Command{ + Use: "generate-docs", + Short: "Generate documentation for tools and toolsets", + Long: `Generate the automated sections of README.md and docs/remote-server.md with current tool and toolset information.`, + RunE: func(_ *cobra.Command, _ []string) error { + return generateAllDocs() + }, +} + +func init() { + rootCmd.AddCommand(generateDocsCmd) +} + +// mockGetClient returns a mock GitHub client for documentation generation +func mockGetClient(_ context.Context) (*gogithub.Client, error) { + return gogithub.NewClient(nil), nil +} + +// mockGetGQLClient returns a mock GraphQL client for documentation generation +func mockGetGQLClient(_ context.Context) (*githubv4.Client, error) { + return githubv4.NewClient(nil), nil +} + +// mockGetRawClient returns a mock raw client for documentation generation +func mockGetRawClient(_ context.Context) (*raw.Client, error) { + return nil, nil +} + +func generateAllDocs() error { + if err := generateReadmeDocs("README.md"); err != nil { + return fmt.Errorf("failed to generate README docs: %w", err) + } + + if err := generateRemoteServerDocs("docs/remote-server.md"); err != nil { + return fmt.Errorf("failed to generate remote-server docs: %w", err) + } + + return nil +} + +func generateReadmeDocs(readmePath string) error { + // Create translation helper + t, _ := translations.TranslationHelper() + + // Create toolset group with mock clients + tsg := github.DefaultToolsetGroup(false, mockGetClient, mockGetGQLClient, mockGetRawClient, t) + + // Generate toolsets documentation + toolsetsDoc := generateToolsetsDoc(tsg) + + // Generate tools documentation + toolsDoc := generateToolsDoc(tsg) + + // Read the current README.md + // #nosec G304 - readmePath is controlled by command line flag, not user input + content, err := os.ReadFile(readmePath) + if err != nil { + return fmt.Errorf("failed to read README.md: %w", err) + } + + // Replace toolsets section + updatedContent := replaceSection(string(content), "START AUTOMATED TOOLSETS", "END AUTOMATED TOOLSETS", toolsetsDoc) + + // Replace tools section + updatedContent = replaceSection(updatedContent, "START AUTOMATED TOOLS", "END AUTOMATED TOOLS", toolsDoc) + + // Write back to file + err = os.WriteFile(readmePath, []byte(updatedContent), 0600) + if err != nil { + return fmt.Errorf("failed to write README.md: %w", err) + } + + fmt.Println("Successfully updated README.md with automated documentation") + return nil +} + +func generateRemoteServerDocs(docsPath string) error { + content, err := os.ReadFile(docsPath) //#nosec G304 + if err != nil { + return fmt.Errorf("failed to read docs file: %w", err) + } + + toolsetsDoc := generateRemoteToolsetsDoc() + + // Replace content between markers + startMarker := "" + endMarker := "" + + contentStr := string(content) + startIndex := strings.Index(contentStr, startMarker) + endIndex := strings.Index(contentStr, endMarker) + + if startIndex == -1 || endIndex == -1 { + return fmt.Errorf("automation markers not found in %s", docsPath) + } + + newContent := contentStr[:startIndex] + startMarker + "\n" + toolsetsDoc + "\n" + endMarker + contentStr[endIndex+len(endMarker):] + + return os.WriteFile(docsPath, []byte(newContent), 0600) //#nosec G306 +} + +func generateToolsetsDoc(tsg *toolsets.ToolsetGroup) string { + var lines []string + + // Add table header and separator + lines = append(lines, "| Toolset | Description |") + lines = append(lines, "| ----------------------- | ------------------------------------------------------------- |") + + // Add the context toolset row (handled separately in README) + lines = append(lines, "| `context` | **Strongly recommended**: Tools that provide context about the current user and GitHub context you are operating in |") + + // Get all toolsets except context (which is handled separately above) + var toolsetNames []string + for name := range tsg.Toolsets { + if name != "context" && name != "dynamic" { // Skip context and dynamic toolsets as they're handled separately + toolsetNames = append(toolsetNames, name) + } + } + + // Sort toolset names for consistent output + sort.Strings(toolsetNames) + + for _, name := range toolsetNames { + toolset := tsg.Toolsets[name] + lines = append(lines, fmt.Sprintf("| `%s` | %s |", name, toolset.Description)) + } + + return strings.Join(lines, "\n") +} + +func generateToolsDoc(tsg *toolsets.ToolsetGroup) string { + var sections []string + + // Get all toolset names and sort them alphabetically for deterministic order + var toolsetNames []string + for name := range tsg.Toolsets { + if name != "dynamic" { // Skip dynamic toolset as it's handled separately + toolsetNames = append(toolsetNames, name) + } + } + sort.Strings(toolsetNames) + + for _, toolsetName := range toolsetNames { + toolset := tsg.Toolsets[toolsetName] + + tools := toolset.GetAvailableTools() + if len(tools) == 0 { + continue + } + + // Sort tools by name for deterministic order + sort.Slice(tools, func(i, j int) bool { + return tools[i].Tool.Name < tools[j].Tool.Name + }) + + // Generate section header - capitalize first letter and replace underscores + sectionName := formatToolsetName(toolsetName) + + var toolDocs []string + for _, serverTool := range tools { + toolDoc := generateToolDoc(serverTool.Tool) + toolDocs = append(toolDocs, toolDoc) + } + + if len(toolDocs) > 0 { + section := fmt.Sprintf("
\n\n%s\n\n%s\n\n
", + sectionName, strings.Join(toolDocs, "\n\n")) + sections = append(sections, section) + } + } + + return strings.Join(sections, "\n\n") +} + +func formatToolsetName(name string) string { + switch name { + case "pull_requests": + return "Pull Requests" + case "repos": + return "Repositories" + case "code_security": + return "Code Security" + case "secret_protection": + return "Secret Protection" + case "orgs": + return "Organizations" + default: + // Fallback: capitalize first letter and replace underscores with spaces + parts := strings.Split(name, "_") + for i, part := range parts { + if len(part) > 0 { + parts[i] = strings.ToUpper(string(part[0])) + part[1:] + } + } + return strings.Join(parts, " ") + } +} + +func generateToolDoc(tool mcp.Tool) string { + var lines []string + + // Tool name only (using annotation name instead of verbose description) + lines = append(lines, fmt.Sprintf("- **%s** - %s", tool.Name, tool.Annotations.Title)) + + // Parameters + schema := tool.InputSchema + if len(schema.Properties) > 0 { + // Get parameter names and sort them for deterministic order + var paramNames []string + for propName := range schema.Properties { + paramNames = append(paramNames, propName) + } + sort.Strings(paramNames) + + for _, propName := range paramNames { + prop := schema.Properties[propName] + required := contains(schema.Required, propName) + requiredStr := "optional" + if required { + requiredStr = "required" + } + + // Get the type and description + typeStr := "unknown" + description := "" + + if propMap, ok := prop.(map[string]interface{}); ok { + if typeVal, ok := propMap["type"].(string); ok { + if typeVal == "array" { + if items, ok := propMap["items"].(map[string]interface{}); ok { + if itemType, ok := items["type"].(string); ok { + typeStr = itemType + "[]" + } + } else { + typeStr = "array" + } + } else { + typeStr = typeVal + } + } + + if desc, ok := propMap["description"].(string); ok { + description = desc + } + } + + paramLine := fmt.Sprintf(" - `%s`: %s (%s, %s)", propName, description, typeStr, requiredStr) + lines = append(lines, paramLine) + } + } else { + lines = append(lines, " - No parameters required") + } + + return strings.Join(lines, "\n") +} + +func contains(slice []string, item string) bool { + for _, s := range slice { + if s == item { + return true + } + } + return false +} + +func replaceSection(content, startMarker, endMarker, newContent string) string { + startPattern := fmt.Sprintf(``, regexp.QuoteMeta(startMarker)) + endPattern := fmt.Sprintf(``, regexp.QuoteMeta(endMarker)) + + re := regexp.MustCompile(fmt.Sprintf(`(?s)%s.*?%s`, startPattern, endPattern)) + + replacement := fmt.Sprintf("\n%s\n", startMarker, newContent, endMarker) + + return re.ReplaceAllString(content, replacement) +} + +func generateRemoteToolsetsDoc() string { + var buf strings.Builder + + // Create translation helper + t, _ := translations.TranslationHelper() + + // Create toolset group with mock clients + tsg := github.DefaultToolsetGroup(false, mockGetClient, mockGetGQLClient, mockGetRawClient, t) + + // Generate table header + buf.WriteString("| Name | Description | API URL | 1-Click Install (VS Code) | Read-only Link | 1-Click Read-only Install (VS Code) |\n") + buf.WriteString("|----------------|--------------------------------------------------|-------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|\n") + + // Get all toolsets + toolsetNames := make([]string, 0, len(tsg.Toolsets)) + for name := range tsg.Toolsets { + if name != "context" && name != "dynamic" { // Skip context and dynamic toolsets as they're handled separately + toolsetNames = append(toolsetNames, name) + } + } + sort.Strings(toolsetNames) + + // Add "all" toolset first (special case) + buf.WriteString("| all | All available GitHub MCP tools | https://api.githubcopilot.com/mcp/ | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=github&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2F%22%7D) | [read-only](https://api.githubcopilot.com/mcp/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=github&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Freadonly%22%7D) |\n") + + // Add individual toolsets + for _, name := range toolsetNames { + toolset := tsg.Toolsets[name] + + formattedName := formatToolsetName(name) + description := toolset.Description + apiURL := fmt.Sprintf("https://api.githubcopilot.com/mcp/x/%s", name) + readonlyURL := fmt.Sprintf("https://api.githubcopilot.com/mcp/x/%s/readonly", name) + + // Create install config JSON (URL encoded) + installConfig := url.QueryEscape(fmt.Sprintf(`{"type": "http","url": "%s"}`, apiURL)) + readonlyConfig := url.QueryEscape(fmt.Sprintf(`{"type": "http","url": "%s"}`, readonlyURL)) + + // Fix URL encoding to use %20 instead of + for spaces + installConfig = strings.ReplaceAll(installConfig, "+", "%20") + readonlyConfig = strings.ReplaceAll(readonlyConfig, "+", "%20") + + installLink := fmt.Sprintf("[Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-%s&config=%s)", name, installConfig) + readonlyInstallLink := fmt.Sprintf("[Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-%s&config=%s)", name, readonlyConfig) + + buf.WriteString(fmt.Sprintf("| %-14s | %-48s | %-53s | %-218s | %-110s | %-288s |\n", + formattedName, + description, + apiURL, + installLink, + fmt.Sprintf("[read-only](%s)", readonlyURL), + readonlyInstallLink, + )) + } + + return buf.String() +} diff --git a/cmd/github-mcp-server/main.go b/cmd/github-mcp-server/main.go index b39a8b7df..cad002666 100644 --- a/cmd/github-mcp-server/main.go +++ b/cmd/github-mcp-server/main.go @@ -103,7 +103,7 @@ func main() { } } -func wordSepNormalizeFunc(f *pflag.FlagSet, name string) pflag.NormalizedName { +func wordSepNormalizeFunc(_ *pflag.FlagSet, name string) pflag.NormalizedName { from := []string{"_"} to := "-" for _, sep := range from { 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/host-integration.md b/docs/host-integration.md index d9f6d9050..9a1d9396f 100644 --- a/docs/host-integration.md +++ b/docs/host-integration.md @@ -64,7 +64,7 @@ flowchart LR - **Local MCP Server**: An MCP Server running locally, side-by-side with the Application. - **Remote MCP Server**: An MCP Server running remotely, accessed via the internet. Most Remote MCP Servers require authentication via OAuth. -For more detail, see the [official MCP specification](https://modelcontextprotocol.io/specification/draft). +For more detail, see the [official MCP specification](https://modelcontextprotocol.io/specification/2025-06-18). > [!NOTE] > GitHub offers both a Local MCP Server and a Remote MCP Server. @@ -84,7 +84,7 @@ For the Remote GitHub MCP Server, the recommended way to obtain a valid access t > The Remote GitHub MCP Server itself does not provide Authentication services. > Your client application must obtain valid GitHub access tokens through one of the supported methods. -The expected flow for obtaining a valid access token via OAuth is depicted in the [MCP Specification](https://modelcontextprotocol.io/specification/draft/basic/authorization#authorization-flow-steps). For convenience, we've embedded a copy of the authorization flow below. Please study it carefully as the remainder of this document is written with this flow in mind. +The expected flow for obtaining a valid access token via OAuth is depicted in the [MCP Specification](https://modelcontextprotocol.io/specification/2025-06-18/basic/authorization#authorization-flow-steps). For convenience, we've embedded a copy of the authorization flow below. Please study it carefully as the remainder of this document is written with this flow in mind. ```mermaid sequenceDiagram 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/remote-server.md b/docs/remote-server.md index 888caef43..49794c605 100644 --- a/docs/remote-server.md +++ b/docs/remote-server.md @@ -10,21 +10,38 @@ Easily connect to the GitHub MCP Server using the hosted version – no local se The remote GitHub MCP server is built using this repository as a library, and binding it into GitHub server infrastructure with an internal repository. You can open issues and propose changes in this repository, and we regularly update the remote server to include the latest version of this code. +The remote server has [additional tools](#toolsets-only-available-in-the-remote-mcp-server) that are not available in the local MCP server, such as the `create_pull_request_with_copilot` tool for invoking Copilot coding agent. + ## Remote MCP Toolsets Below is a table of available toolsets for the remote GitHub MCP Server. Each toolset is provided as a distinct URL so you can mix and match to create the perfect combination of tools for your use-case. Add `/readonly` to the end of any URL to restrict the tools in the toolset to only those that enable read access. We also provide the option to use [headers](#headers) instead. - + | Name | Description | API URL | 1-Click Install (VS Code) | Read-only Link | 1-Click Read-only Install (VS Code) | |----------------|--------------------------------------------------|-------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | all | All available GitHub MCP tools | https://api.githubcopilot.com/mcp/ | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=github&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2F%22%7D) | [read-only](https://api.githubcopilot.com/mcp/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=github&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Freadonly%22%7D) | -| code_security | Code security related tools, such as Code Scanning| https://api.githubcopilot.com/mcp/x/code_security | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-code_security&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcode_security%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/code_security/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-code_security&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcode_security%2Freadonly%22%7D)| -| issues | GitHub Issues related tools | https://api.githubcopilot.com/mcp/x/issues | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-issues&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fissues%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/issues/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-issues&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fissues%2Freadonly%22%7D) | -| notifications | GitHub Notifications related tools | https://api.githubcopilot.com/mcp/x/notifications | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-notifications&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fnotifications%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/notifications/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-notifications&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fnotifications%2Freadonly%22%7D)| -| pull_requests | GitHub Pull Request related tools | https://api.githubcopilot.com/mcp/x/pull_requests | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-pull_requests&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fpull_requests%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/pull_requests/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-pull_requests&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fpull_requests%2Freadonly%22%7D)| -| repos | GitHub Repository related tools | https://api.githubcopilot.com/mcp/x/repos | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-repos&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Frepos%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/repos/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-repos&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Frepos%2Freadonly%22%7D) | -| secret_protection | Secret protection related tools, e.g. Secret Scanning | https://api.githubcopilot.com/mcp/x/secret_protection | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-secret_protection&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fsecret_protection%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/secret_protection/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-secret_protection&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fsecret_protection%2Freadonly%22%7D)| -| users | GitHub User related tools | https://api.githubcopilot.com/mcp/x/users | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-users&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fusers%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/users/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-users&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fusers%2Freadonly%22%7D) | +| Actions | GitHub Actions workflows and CI/CD operations | https://api.githubcopilot.com/mcp/x/actions | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-actions&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Factions%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/actions/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-actions&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Factions%2Freadonly%22%7D) | +| Code Security | Code security related tools, such as GitHub Code Scanning | https://api.githubcopilot.com/mcp/x/code_security | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-code_security&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcode_security%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/code_security/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-code_security&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcode_security%2Freadonly%22%7D) | +| Dependabot | Dependabot tools | https://api.githubcopilot.com/mcp/x/dependabot | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-dependabot&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fdependabot%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/dependabot/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-dependabot&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fdependabot%2Freadonly%22%7D) | +| Discussions | GitHub Discussions related tools | https://api.githubcopilot.com/mcp/x/discussions | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-discussions&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fdiscussions%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/discussions/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-discussions&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fdiscussions%2Freadonly%22%7D) | +| Experiments | Experimental features that are not considered stable yet | https://api.githubcopilot.com/mcp/x/experiments | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-experiments&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fexperiments%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/experiments/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-experiments&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fexperiments%2Freadonly%22%7D) | +| Issues | GitHub Issues related tools | https://api.githubcopilot.com/mcp/x/issues | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-issues&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fissues%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/issues/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-issues&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fissues%2Freadonly%22%7D) | +| Notifications | GitHub Notifications related tools | https://api.githubcopilot.com/mcp/x/notifications | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-notifications&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fnotifications%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/notifications/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-notifications&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fnotifications%2Freadonly%22%7D) | +| Organizations | GitHub Organization related tools | https://api.githubcopilot.com/mcp/x/orgs | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-orgs&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Forgs%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/orgs/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-orgs&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Forgs%2Freadonly%22%7D) | +| Pull Requests | GitHub Pull Request related tools | https://api.githubcopilot.com/mcp/x/pull_requests | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-pull_requests&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fpull_requests%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/pull_requests/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-pull_requests&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fpull_requests%2Freadonly%22%7D) | +| Repositories | GitHub Repository related tools | https://api.githubcopilot.com/mcp/x/repos | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-repos&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Frepos%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/repos/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-repos&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Frepos%2Freadonly%22%7D) | +| Secret Protection | Secret protection related tools, such as GitHub Secret Scanning | https://api.githubcopilot.com/mcp/x/secret_protection | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-secret_protection&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fsecret_protection%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/secret_protection/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-secret_protection&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fsecret_protection%2Freadonly%22%7D) | +| Users | GitHub User related tools | https://api.githubcopilot.com/mcp/x/users | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-users&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fusers%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/users/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-users&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fusers%2Freadonly%22%7D) | + + + +### Additional _Remote_ Server Toolsets + +These toolsets are only available in the remote GitHub MCP Server and are not included in the local MCP server. + +| Name | Description | API URL | 1-Click Install (VS Code) | Read-only Link | 1-Click Read-only Install (VS Code) | +| -------------------- | --------------------------------------------- | ------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Copilot coding agent | Perform task with GitHub Copilot coding agent | https://api.githubcopilot.com/mcp/x/copilot | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-copilot&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcopilot%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/copilot/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-copilot&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcopilot%2Freadonly%22%7D) | ### Headers 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 409f20545..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" ) @@ -260,7 +260,7 @@ func TestGitHubErrorContext(t *testing.T) { t.Run("NewGitHubAPIErrorToCtx with nil context does not error", func(t *testing.T) { // Given a nil context - var ctx context.Context = nil + var ctx context.Context // Create a mock GitHub response resp := &github.Response{ 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__/create_or_update_file.snap b/pkg/github/__toolsnaps__/create_or_update_file.snap index dfbb34423..61adef72c 100644 --- a/pkg/github/__toolsnaps__/create_or_update_file.snap +++ b/pkg/github/__toolsnaps__/create_or_update_file.snap @@ -31,7 +31,7 @@ "type": "string" }, "sha": { - "description": "SHA of file being replaced (for updates)", + "description": "Required if updating an existing file. The blob SHA of the file being replaced.", "type": "string" } }, diff --git a/pkg/github/__toolsnaps__/get_dependabot_alert.snap b/pkg/github/__toolsnaps__/get_dependabot_alert.snap new file mode 100644 index 000000000..76b5ef126 --- /dev/null +++ b/pkg/github/__toolsnaps__/get_dependabot_alert.snap @@ -0,0 +1,30 @@ +{ + "annotations": { + "title": "Get dependabot alert", + "readOnlyHint": true + }, + "description": "Get details of a specific dependabot alert in a GitHub repository.", + "inputSchema": { + "properties": { + "alertNumber": { + "description": "The number of the alert.", + "type": "number" + }, + "owner": { + "description": "The owner of the repository.", + "type": "string" + }, + "repo": { + "description": "The name of the repository.", + "type": "string" + } + }, + "required": [ + "owner", + "repo", + "alertNumber" + ], + "type": "object" + }, + "name": "get_dependabot_alert" +} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/get_file_contents.snap b/pkg/github/__toolsnaps__/get_file_contents.snap index b3975abbc..53f5a29e5 100644 --- a/pkg/github/__toolsnaps__/get_file_contents.snap +++ b/pkg/github/__toolsnaps__/get_file_contents.snap @@ -11,6 +11,7 @@ "type": "string" }, "path": { + "default": "/", "description": "Path to file/directory (directories must end with a slash '/')", "type": "string" }, @@ -23,14 +24,13 @@ "type": "string" }, "sha": { - "description": "Accepts optional git sha, if sha is specified it will be used instead of ref", + "description": "Accepts optional commit SHA. If specified, it will be used instead of ref", "type": "string" } }, "required": [ "owner", - "repo", - "path" + "repo" ], "type": "object" }, diff --git a/pkg/github/__toolsnaps__/get_issue_comments.snap b/pkg/github/__toolsnaps__/get_issue_comments.snap index fa1fb0d6c..b28f45204 100644 --- a/pkg/github/__toolsnaps__/get_issue_comments.snap +++ b/pkg/github/__toolsnaps__/get_issue_comments.snap @@ -15,11 +15,14 @@ "type": "string" }, "page": { - "description": "Page number", + "description": "Page number for pagination (min 1)", + "minimum": 1, "type": "number" }, - "per_page": { - "description": "Number of records per page", + "perPage": { + "description": "Results per page for pagination (min 1, max 100)", + "maximum": 100, + "minimum": 1, "type": "number" }, "repo": { diff --git a/pkg/github/__toolsnaps__/get_me.snap b/pkg/github/__toolsnaps__/get_me.snap index fc098f9d1..13b061741 100644 --- a/pkg/github/__toolsnaps__/get_me.snap +++ b/pkg/github/__toolsnaps__/get_me.snap @@ -3,14 +3,9 @@ "title": "Get my user profile", "readOnlyHint": true }, - "description": "Get details of the authenticated GitHub user. Use this when a request includes \"me\", \"my\". The output will not change unless the user changes their profile, so only call this once.", + "description": "Get details of the authenticated GitHub user. Use this when a request is about the user's own profile for GitHub. Or when information is missing to build other tool calls.", "inputSchema": { - "properties": { - "reason": { - "description": "Optional: the reason for requesting the user information", - "type": "string" - } - }, + "properties": {}, "type": "object" }, "name": "get_me" diff --git a/pkg/github/__toolsnaps__/list_commits.snap b/pkg/github/__toolsnaps__/list_commits.snap index 1e769c718..a802436c2 100644 --- a/pkg/github/__toolsnaps__/list_commits.snap +++ b/pkg/github/__toolsnaps__/list_commits.snap @@ -3,11 +3,11 @@ "title": "List commits", "readOnlyHint": true }, - "description": "Get list of commits of a branch in a GitHub repository", + "description": "Get list of commits of a branch in a GitHub repository. Returns at least 30 results per page by default, but can return more if specified using the perPage parameter (up to 100).", "inputSchema": { "properties": { "author": { - "description": "Author username or email address", + "description": "Author username or email address to filter commits by", "type": "string" }, "owner": { @@ -30,7 +30,7 @@ "type": "string" }, "sha": { - "description": "SHA or Branch name", + "description": "Commit SHA, branch or tag name to list commits of. If not provided, uses the default branch of the repository. If a commit SHA is provided, will list commits up to that SHA.", "type": "string" } }, diff --git a/pkg/github/__toolsnaps__/list_dependabot_alerts.snap b/pkg/github/__toolsnaps__/list_dependabot_alerts.snap new file mode 100644 index 000000000..681d640b7 --- /dev/null +++ b/pkg/github/__toolsnaps__/list_dependabot_alerts.snap @@ -0,0 +1,46 @@ +{ + "annotations": { + "title": "List dependabot alerts", + "readOnlyHint": true + }, + "description": "List dependabot alerts in a GitHub repository.", + "inputSchema": { + "properties": { + "owner": { + "description": "The owner of the repository.", + "type": "string" + }, + "repo": { + "description": "The name of the repository.", + "type": "string" + }, + "severity": { + "description": "Filter dependabot alerts by severity", + "enum": [ + "low", + "medium", + "high", + "critical" + ], + "type": "string" + }, + "state": { + "default": "open", + "description": "Filter dependabot alerts by state. Defaults to open", + "enum": [ + "open", + "fixed", + "dismissed", + "auto_dismissed" + ], + "type": "string" + } + }, + "required": [ + "owner", + "repo" + ], + "type": "object" + }, + "name": "list_dependabot_alerts" +} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/list_pull_requests.snap b/pkg/github/__toolsnaps__/list_pull_requests.snap index b8369784d..fee7e2ff1 100644 --- a/pkg/github/__toolsnaps__/list_pull_requests.snap +++ b/pkg/github/__toolsnaps__/list_pull_requests.snap @@ -3,7 +3,7 @@ "title": "List pull requests", "readOnlyHint": true }, - "description": "List pull requests in a GitHub repository.", + "description": "List pull requests in a GitHub repository. If the user specifies an author, then DO NOT use this tool and use the search_pull_requests tool instead.", "inputSchema": { "properties": { "base": { 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 cf33fb5a8..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" ) @@ -37,12 +37,7 @@ func ListWorkflows(getClient GetClientFn, t translations.TranslationHelperFunc) mcp.Required(), mcp.Description(DescriptionRepositoryName), ), - mcp.WithNumber("per_page", - mcp.Description("The number of results per page (max 100)"), - ), - mcp.WithNumber("page", - mcp.Description("The page number of the results to fetch"), - ), + WithPagination(), ), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { owner, err := RequiredParam[string](request, "owner") @@ -55,11 +50,7 @@ func ListWorkflows(getClient GetClientFn, t translations.TranslationHelperFunc) } // Get optional pagination parameters - perPage, err := OptionalIntParam(request, "per_page") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - page, err := OptionalIntParam(request, "page") + pagination, err := OptionalPaginationParams(request) if err != nil { return mcp.NewToolResultError(err.Error()), nil } @@ -71,8 +62,8 @@ func ListWorkflows(getClient GetClientFn, t translations.TranslationHelperFunc) // Set up list options opts := &github.ListOptions{ - PerPage: perPage, - Page: page, + PerPage: pagination.PerPage, + Page: pagination.Page, } workflows, resp, err := client.Actions.ListWorkflows(ctx, owner, repo, opts) @@ -157,12 +148,7 @@ func ListWorkflowRuns(getClient GetClientFn, t translations.TranslationHelperFun mcp.Description("Returns workflow runs with the check run status"), mcp.Enum("queued", "in_progress", "completed", "requested", "waiting"), ), - mcp.WithNumber("per_page", - mcp.Description("The number of results per page (max 100)"), - ), - mcp.WithNumber("page", - mcp.Description("The page number of the results to fetch"), - ), + WithPagination(), ), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { owner, err := RequiredParam[string](request, "owner") @@ -197,11 +183,7 @@ func ListWorkflowRuns(getClient GetClientFn, t translations.TranslationHelperFun } // Get optional pagination parameters - perPage, err := OptionalIntParam(request, "per_page") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - page, err := OptionalIntParam(request, "page") + pagination, err := OptionalPaginationParams(request) if err != nil { return mcp.NewToolResultError(err.Error()), nil } @@ -218,8 +200,8 @@ func ListWorkflowRuns(getClient GetClientFn, t translations.TranslationHelperFun Event: event, Status: status, ListOptions: github.ListOptions{ - PerPage: perPage, - Page: page, + PerPage: pagination.PerPage, + Page: pagination.Page, }, } @@ -483,12 +465,7 @@ func ListWorkflowJobs(getClient GetClientFn, t translations.TranslationHelperFun mcp.Description("Filters jobs by their completed_at timestamp"), mcp.Enum("latest", "all"), ), - mcp.WithNumber("per_page", - mcp.Description("The number of results per page (max 100)"), - ), - mcp.WithNumber("page", - mcp.Description("The page number of the results to fetch"), - ), + WithPagination(), ), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { owner, err := RequiredParam[string](request, "owner") @@ -512,11 +489,7 @@ func ListWorkflowJobs(getClient GetClientFn, t translations.TranslationHelperFun } // Get optional pagination parameters - perPage, err := OptionalIntParam(request, "per_page") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - page, err := OptionalIntParam(request, "page") + pagination, err := OptionalPaginationParams(request) if err != nil { return mcp.NewToolResultError(err.Error()), nil } @@ -530,8 +503,8 @@ func ListWorkflowJobs(getClient GetClientFn, t translations.TranslationHelperFun opts := &github.ListWorkflowJobsOptions{ Filter: filter, ListOptions: github.ListOptions{ - PerPage: perPage, - Page: page, + PerPage: pagination.PerPage, + Page: pagination.Page, }, } @@ -584,6 +557,10 @@ func GetJobLogs(getClient GetClientFn, t translations.TranslationHelperFunc) (to mcp.WithBoolean("return_content", mcp.Description("Returns actual log content instead of URLs"), ), + mcp.WithNumber("tail_lines", + mcp.Description("Number of lines to return from the end of the log"), + mcp.DefaultNumber(500), + ), ), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { owner, err := RequiredParam[string](request, "owner") @@ -612,6 +589,14 @@ func GetJobLogs(getClient GetClientFn, t translations.TranslationHelperFunc) (to if err != nil { return mcp.NewToolResultError(err.Error()), nil } + tailLines, err := OptionalIntParam(request, "tail_lines") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + // Default to 500 lines if not specified + if tailLines == 0 { + tailLines = 500 + } client, err := getClient(ctx) if err != nil { @@ -628,10 +613,10 @@ func GetJobLogs(getClient GetClientFn, t translations.TranslationHelperFunc) (to if failedOnly && runID > 0 { // Handle failed-only mode: get logs for all failed jobs in the workflow run - return handleFailedJobLogs(ctx, client, owner, repo, int64(runID), returnContent) + return handleFailedJobLogs(ctx, client, owner, repo, int64(runID), returnContent, tailLines) } else if jobID > 0 { // Handle single job mode - return handleSingleJobLogs(ctx, client, owner, repo, int64(jobID), returnContent) + return handleSingleJobLogs(ctx, client, owner, repo, int64(jobID), returnContent, tailLines) } return mcp.NewToolResultError("Either job_id must be provided for single job logs, or run_id with failed_only=true for failed job logs"), nil @@ -639,7 +624,7 @@ func GetJobLogs(getClient GetClientFn, t translations.TranslationHelperFunc) (to } // handleFailedJobLogs gets logs for all failed jobs in a workflow run -func handleFailedJobLogs(ctx context.Context, client *github.Client, owner, repo string, runID int64, returnContent bool) (*mcp.CallToolResult, error) { +func handleFailedJobLogs(ctx context.Context, client *github.Client, owner, repo string, runID int64, returnContent bool, tailLines int) (*mcp.CallToolResult, error) { // First, get all jobs for the workflow run jobs, resp, err := client.Actions.ListWorkflowJobs(ctx, owner, repo, runID, &github.ListWorkflowJobsOptions{ Filter: "latest", @@ -671,7 +656,7 @@ func handleFailedJobLogs(ctx context.Context, client *github.Client, owner, repo // Collect logs for all failed jobs var logResults []map[string]any for _, job := range failedJobs { - jobResult, resp, err := getJobLogData(ctx, client, owner, repo, job.GetID(), job.GetName(), returnContent) + jobResult, resp, err := getJobLogData(ctx, client, owner, repo, job.GetID(), job.GetName(), returnContent, tailLines) if err != nil { // Continue with other jobs even if one fails jobResult = map[string]any{ @@ -704,8 +689,8 @@ func handleFailedJobLogs(ctx context.Context, client *github.Client, owner, repo } // handleSingleJobLogs gets logs for a single job -func handleSingleJobLogs(ctx context.Context, client *github.Client, owner, repo string, jobID int64, returnContent bool) (*mcp.CallToolResult, error) { - jobResult, resp, err := getJobLogData(ctx, client, owner, repo, jobID, "", returnContent) +func handleSingleJobLogs(ctx context.Context, client *github.Client, owner, repo string, jobID int64, returnContent bool, tailLines int) (*mcp.CallToolResult, error) { + jobResult, resp, err := getJobLogData(ctx, client, owner, repo, jobID, "", returnContent, tailLines) if err != nil { return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to get job logs", resp, err), nil } @@ -719,7 +704,7 @@ func handleSingleJobLogs(ctx context.Context, client *github.Client, owner, repo } // getJobLogData retrieves log data for a single job, either as URL or content -func getJobLogData(ctx context.Context, client *github.Client, owner, repo string, jobID int64, jobName string, returnContent bool) (map[string]any, *github.Response, error) { +func getJobLogData(ctx context.Context, client *github.Client, owner, repo string, jobID int64, jobName string, returnContent bool, tailLines int) (map[string]any, *github.Response, error) { // Get the download URL for the job logs url, resp, err := client.Actions.GetWorkflowJobLogs(ctx, owner, repo, jobID, 1) if err != nil { @@ -736,7 +721,7 @@ func getJobLogData(ctx context.Context, client *github.Client, owner, repo strin if returnContent { // Download and return the actual log content - content, httpResp, err := downloadLogContent(url.String()) //nolint:bodyclose // Response body is closed in downloadLogContent, but we need to return httpResp + content, originalLength, httpResp, err := downloadLogContent(url.String(), tailLines) //nolint:bodyclose // Response body is closed in downloadLogContent, but we need to return httpResp if err != nil { // To keep the return value consistent wrap the response as a GitHub Response ghRes := &github.Response{ @@ -746,6 +731,7 @@ func getJobLogData(ctx context.Context, client *github.Client, owner, repo strin } result["logs_content"] = content result["message"] = "Job logs content retrieved successfully" + result["original_length"] = originalLength } else { // Return just the URL result["logs_url"] = url.String() @@ -757,25 +743,47 @@ func getJobLogData(ctx context.Context, client *github.Client, owner, repo strin } // downloadLogContent downloads the actual log content from a GitHub logs URL -func downloadLogContent(logURL string) (string, *http.Response, error) { +func downloadLogContent(logURL string, tailLines int) (string, int, *http.Response, error) { httpResp, err := http.Get(logURL) //nolint:gosec // URLs are provided by GitHub API and are safe if err != nil { - return "", httpResp, fmt.Errorf("failed to download logs: %w", err) + return "", 0, httpResp, fmt.Errorf("failed to download logs: %w", err) } defer func() { _ = httpResp.Body.Close() }() if httpResp.StatusCode != http.StatusOK { - return "", httpResp, fmt.Errorf("failed to download logs: HTTP %d", httpResp.StatusCode) + return "", 0, httpResp, fmt.Errorf("failed to download logs: HTTP %d", httpResp.StatusCode) } content, err := io.ReadAll(httpResp.Body) if err != nil { - return "", httpResp, fmt.Errorf("failed to read log content: %w", err) + return "", 0, httpResp, fmt.Errorf("failed to read log content: %w", err) } // Clean up and format the log content for better readability logContent := strings.TrimSpace(string(content)) - return logContent, httpResp, nil + + trimmedContent, lineCount := trimContent(logContent, tailLines) + return trimmedContent, lineCount, httpResp, nil +} + +// trimContent trims the content to a maximum length and returns the trimmed content and an original length +func trimContent(content string, tailLines int) (string, int) { + // Truncate to tail_lines if specified + lineCount := 0 + if tailLines > 0 { + + // Count backwards to find the nth newline from the end and a total number of lines + for i := len(content) - 1; i >= 0 && lineCount < tailLines; i-- { + if content[i] == '\n' { + lineCount++ + // If we have reached the tailLines, trim the content + if lineCount == tailLines { + content = content[i+1:] + } + } + } + } + return content, lineCount } // RerunWorkflowRun creates a tool to re-run an entire workflow run @@ -987,12 +995,7 @@ func ListWorkflowRunArtifacts(getClient GetClientFn, t translations.TranslationH mcp.Required(), mcp.Description("The unique identifier of the workflow run"), ), - mcp.WithNumber("per_page", - mcp.Description("The number of results per page (max 100)"), - ), - mcp.WithNumber("page", - mcp.Description("The page number of the results to fetch"), - ), + WithPagination(), ), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { owner, err := RequiredParam[string](request, "owner") @@ -1010,11 +1013,7 @@ func ListWorkflowRunArtifacts(getClient GetClientFn, t translations.TranslationH runID := int64(runIDInt) // Get optional pagination parameters - perPage, err := OptionalIntParam(request, "per_page") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - page, err := OptionalIntParam(request, "page") + pagination, err := OptionalPaginationParams(request) if err != nil { return mcp.NewToolResultError(err.Error()), nil } @@ -1026,8 +1025,8 @@ func ListWorkflowRunArtifacts(getClient GetClientFn, t translations.TranslationH // Set up list options opts := &github.ListOptions{ - PerPage: perPage, - Page: 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 388c0bbe2..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" @@ -23,7 +23,7 @@ func Test_ListWorkflows(t *testing.T) { assert.NotEmpty(t, tool.Description) assert.Contains(t, tool.InputSchema.Properties, "owner") assert.Contains(t, tool.InputSchema.Properties, "repo") - assert.Contains(t, tool.InputSchema.Properties, "per_page") + assert.Contains(t, tool.InputSchema.Properties, "perPage") assert.Contains(t, tool.InputSchema.Properties, "page") assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo"}) @@ -393,7 +393,7 @@ func Test_ListWorkflowRunArtifacts(t *testing.T) { assert.Contains(t, tool.InputSchema.Properties, "owner") assert.Contains(t, tool.InputSchema.Properties, "repo") assert.Contains(t, tool.InputSchema.Properties, "run_id") - assert.Contains(t, tool.InputSchema.Properties, "per_page") + assert.Contains(t, tool.InputSchema.Properties, "perPage") assert.Contains(t, tool.InputSchema.Properties, "page") assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "run_id"}) @@ -538,7 +538,7 @@ func Test_DownloadWorkflowRunArtifact(t *testing.T) { Pattern: "/repos/owner/repo/actions/artifacts/123/zip", Method: "GET", }, - http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { // GitHub returns a 302 redirect to the download URL w.Header().Set("Location", "https://api.github.com/repos/owner/repo/actions/artifacts/123/download") w.WriteHeader(http.StatusFound) @@ -1055,7 +1055,7 @@ func Test_GetJobLogs_WithContentReturn(t *testing.T) { logContent := "2023-01-01T10:00:00.000Z Starting job...\n2023-01-01T10:00:01.000Z Running tests...\n2023-01-01T10:00:02.000Z Job completed successfully" // Create a test server to serve log content - testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(logContent)) })) @@ -1095,3 +1095,52 @@ func Test_GetJobLogs_WithContentReturn(t *testing.T) { assert.Equal(t, "Job logs content retrieved successfully", response["message"]) assert.NotContains(t, response, "logs_url") // Should not have URL when returning content } + +func Test_GetJobLogs_WithContentReturnAndTailLines(t *testing.T) { + // Test the return_content functionality with a mock HTTP server + logContent := "2023-01-01T10:00:00.000Z Starting job...\n2023-01-01T10:00:01.000Z Running tests...\n2023-01-01T10:00:02.000Z Job completed successfully" + expectedLogContent := "2023-01-01T10:00:02.000Z Job completed successfully" + + // Create a test server to serve log content + testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(logContent)) + })) + defer testServer.Close() + + mockedClient := mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposActionsJobsLogsByOwnerByRepoByJobId, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Location", testServer.URL) + w.WriteHeader(http.StatusFound) + }), + ), + ) + + client := github.NewClient(mockedClient) + _, handler := GetJobLogs(stubGetClientFn(client), translations.NullTranslationHelper) + + request := createMCPRequest(map[string]any{ + "owner": "owner", + "repo": "repo", + "job_id": float64(123), + "return_content": true, + "tail_lines": float64(1), // Requesting last 1 line + }) + + result, err := handler(context.Background(), request) + require.NoError(t, err) + require.False(t, result.IsError) + + textContent := getTextResult(t, result) + var response map[string]any + err = json.Unmarshal([]byte(textContent.Text), &response) + require.NoError(t, err) + + assert.Equal(t, float64(123), response["job_id"]) + assert.Equal(t, float64(1), response["original_length"]) + assert.Equal(t, expectedLogContent, response["logs_content"]) + assert.Equal(t, "Job logs content retrieved successfully", response["message"]) + assert.NotContains(t, response, "logs_url") // Should not have URL when returning content +} 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.go b/pkg/github/context_tools.go index bed2f4a39..9817fea7b 100644 --- a/pkg/github/context_tools.go +++ b/pkg/github/context_tools.go @@ -2,6 +2,7 @@ package github import ( "context" + "time" ghErrors "github.com/github/github-mcp-server/pkg/errors" "github.com/github/github-mcp-server/pkg/translations" @@ -9,17 +10,36 @@ import ( "github.com/mark3labs/mcp-go/server" ) +// UserDetails contains additional fields about a GitHub user not already +// present in MinimalUser. Used by get_me context tool but omitted from search_users. +type UserDetails struct { + Name string `json:"name,omitempty"` + Company string `json:"company,omitempty"` + Blog string `json:"blog,omitempty"` + Location string `json:"location,omitempty"` + Email string `json:"email,omitempty"` + Hireable bool `json:"hireable,omitempty"` + Bio string `json:"bio,omitempty"` + TwitterUsername string `json:"twitter_username,omitempty"` + PublicRepos int `json:"public_repos"` + PublicGists int `json:"public_gists"` + Followers int `json:"followers"` + Following int `json:"following"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + PrivateGists int `json:"private_gists,omitempty"` + TotalPrivateRepos int64 `json:"total_private_repos,omitempty"` + OwnedPrivateRepos int64 `json:"owned_private_repos,omitempty"` +} + // GetMe creates a tool to get details of the authenticated user. func GetMe(getClient GetClientFn, t translations.TranslationHelperFunc) (mcp.Tool, server.ToolHandlerFunc) { tool := mcp.NewTool("get_me", - mcp.WithDescription(t("TOOL_GET_ME_DESCRIPTION", "Get details of the authenticated GitHub user. Use this when a request includes \"me\", \"my\". The output will not change unless the user changes their profile, so only call this once.")), + mcp.WithDescription(t("TOOL_GET_ME_DESCRIPTION", "Get details of the authenticated GitHub user. Use this when a request is about the user's own profile for GitHub. Or when information is missing to build other tool calls.")), mcp.WithToolAnnotation(mcp.ToolAnnotation{ Title: t("TOOL_GET_ME_USER_TITLE", "Get my user profile"), ReadOnlyHint: ToBoolPtr(true), }), - mcp.WithString("reason", - mcp.Description("Optional: the reason for requesting the user information"), - ), ) type args struct{} @@ -38,7 +58,34 @@ func GetMe(getClient GetClientFn, t translations.TranslationHelperFunc) (mcp.Too ), nil } - return MarshalledTextResult(user), nil + // Create minimal user representation instead of returning full user object + minimalUser := MinimalUser{ + Login: user.GetLogin(), + ID: user.GetID(), + ProfileURL: user.GetHTMLURL(), + AvatarURL: user.GetAvatarURL(), + Details: &UserDetails{ + Name: user.GetName(), + Company: user.GetCompany(), + Blog: user.GetBlog(), + Location: user.GetLocation(), + Email: user.GetEmail(), + Hireable: user.GetHireable(), + Bio: user.GetBio(), + TwitterUsername: user.GetTwitterUsername(), + PublicRepos: user.GetPublicRepos(), + PublicGists: user.GetPublicGists(), + Followers: user.GetFollowers(), + Following: user.GetFollowing(), + CreatedAt: user.GetCreatedAt().Time, + UpdatedAt: user.GetUpdatedAt().Time, + PrivateGists: user.GetPrivateGists(), + TotalPrivateRepos: user.GetTotalPrivateRepos(), + OwnedPrivateRepos: user.GetOwnedPrivateRepos(), + }, + } + + return MarshalledTextResult(minimalUser), nil }) return tool, handler diff --git a/pkg/github/context_tools_test.go b/pkg/github/context_tools_test.go index 0d9193976..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" @@ -26,15 +26,17 @@ func Test_GetMe(t *testing.T) { // Setup mock user response mockUser := &github.User{ - Login: github.Ptr("testuser"), - Name: github.Ptr("Test User"), - Email: github.Ptr("test@example.com"), - Bio: github.Ptr("GitHub user for testing"), - Company: github.Ptr("Test Company"), - Location: github.Ptr("Test Location"), - HTMLURL: github.Ptr("https://github.com/testuser"), - CreatedAt: &github.Timestamp{Time: time.Now().Add(-365 * 24 * time.Hour)}, - Type: github.Ptr("User"), + Login: github.Ptr("testuser"), + Name: github.Ptr("Test User"), + Email: github.Ptr("test@example.com"), + Bio: github.Ptr("GitHub user for testing"), + Company: github.Ptr("Test Company"), + Location: github.Ptr("Test Location"), + HTMLURL: github.Ptr("https://github.com/testuser"), + CreatedAt: &github.Timestamp{Time: time.Now().Add(-365 * 24 * time.Hour)}, + Type: github.Ptr("User"), + Hireable: github.Ptr(true), + TwitterUsername: github.Ptr("testuser_twitter"), Plan: &github.Plan{ Name: github.Ptr("pro"), }, @@ -117,17 +119,23 @@ func Test_GetMe(t *testing.T) { } // Unmarshal and verify the result - var returnedUser github.User + var returnedUser MinimalUser err = json.Unmarshal([]byte(textContent.Text), &returnedUser) require.NoError(t, err) + // Verify minimal user details + assert.Equal(t, *tc.expectedUser.Login, returnedUser.Login) + assert.Equal(t, *tc.expectedUser.HTMLURL, returnedUser.ProfileURL) + // Verify user details - assert.Equal(t, *tc.expectedUser.Login, *returnedUser.Login) - assert.Equal(t, *tc.expectedUser.Name, *returnedUser.Name) - assert.Equal(t, *tc.expectedUser.Email, *returnedUser.Email) - assert.Equal(t, *tc.expectedUser.Bio, *returnedUser.Bio) - assert.Equal(t, *tc.expectedUser.HTMLURL, *returnedUser.HTMLURL) - assert.Equal(t, *tc.expectedUser.Type, *returnedUser.Type) + require.NotNil(t, returnedUser.Details) + assert.Equal(t, *tc.expectedUser.Name, returnedUser.Details.Name) + assert.Equal(t, *tc.expectedUser.Email, returnedUser.Details.Email) + assert.Equal(t, *tc.expectedUser.Bio, returnedUser.Details.Bio) + assert.Equal(t, *tc.expectedUser.Company, returnedUser.Details.Company) + assert.Equal(t, *tc.expectedUser.Location, returnedUser.Details.Location) + assert.Equal(t, *tc.expectedUser.Hireable, returnedUser.Details.Hireable) + assert.Equal(t, *tc.expectedUser.TwitterUsername, returnedUser.Details.TwitterUsername) }) } } diff --git a/pkg/github/dependabot.go b/pkg/github/dependabot.go new file mode 100644 index 000000000..c2a4d5b0d --- /dev/null +++ b/pkg/github/dependabot.go @@ -0,0 +1,161 @@ +package github + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + + ghErrors "github.com/github/github-mcp-server/pkg/errors" + "github.com/github/github-mcp-server/pkg/translations" + "github.com/google/go-github/v73/github" + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" +) + +func GetDependabotAlert(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool( + "get_dependabot_alert", + mcp.WithDescription(t("TOOL_GET_DEPENDABOT_ALERT_DESCRIPTION", "Get details of a specific dependabot alert in a GitHub repository.")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_GET_DEPENDABOT_ALERT_USER_TITLE", "Get dependabot alert"), + ReadOnlyHint: ToBoolPtr(true), + }), + mcp.WithString("owner", + mcp.Required(), + mcp.Description("The owner of the repository."), + ), + mcp.WithString("repo", + mcp.Required(), + mcp.Description("The name of the repository."), + ), + mcp.WithNumber("alertNumber", + mcp.Required(), + mcp.Description("The number of the alert."), + ), + ), + 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 + } + alertNumber, err := RequiredInt(request, "alertNumber") + 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) + } + + alert, resp, err := client.Dependabot.GetRepoAlert(ctx, owner, repo, alertNumber) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + fmt.Sprintf("failed to get alert with number '%d'", alertNumber), + resp, + err, + ), nil + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + return mcp.NewToolResultError(fmt.Sprintf("failed to get alert: %s", string(body))), nil + } + + r, err := json.Marshal(alert) + if err != nil { + return nil, fmt.Errorf("failed to marshal alert: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil + } +} + +func ListDependabotAlerts(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool( + "list_dependabot_alerts", + mcp.WithDescription(t("TOOL_LIST_DEPENDABOT_ALERTS_DESCRIPTION", "List dependabot alerts in a GitHub repository.")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_LIST_DEPENDABOT_ALERTS_USER_TITLE", "List dependabot alerts"), + ReadOnlyHint: ToBoolPtr(true), + }), + mcp.WithString("owner", + mcp.Required(), + mcp.Description("The owner of the repository."), + ), + mcp.WithString("repo", + mcp.Required(), + mcp.Description("The name of the repository."), + ), + mcp.WithString("state", + mcp.Description("Filter dependabot alerts by state. Defaults to open"), + mcp.DefaultString("open"), + mcp.Enum("open", "fixed", "dismissed", "auto_dismissed"), + ), + mcp.WithString("severity", + mcp.Description("Filter dependabot alerts by severity"), + mcp.Enum("low", "medium", "high", "critical"), + ), + ), + 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 + } + state, err := OptionalParam[string](request, "state") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + severity, err := OptionalParam[string](request, "severity") + 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) + } + + alerts, resp, err := client.Dependabot.ListRepoAlerts(ctx, owner, repo, &github.ListAlertsOptions{ + State: ToStringPtr(state), + Severity: ToStringPtr(severity), + }) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + fmt.Sprintf("failed to list alerts for repository '%s/%s'", owner, repo), + 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 alerts: %s", string(body))), nil + } + + r, err := json.Marshal(alerts) + if err != nil { + return nil, fmt.Errorf("failed to marshal alerts: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil + } +} diff --git a/pkg/github/dependabot_test.go b/pkg/github/dependabot_test.go new file mode 100644 index 000000000..8a7270d7f --- /dev/null +++ b/pkg/github/dependabot_test.go @@ -0,0 +1,276 @@ +package github + +import ( + "context" + "encoding/json" + "net/http" + "testing" + + "github.com/github/github-mcp-server/internal/toolsnaps" + "github.com/github/github-mcp-server/pkg/translations" + "github.com/google/go-github/v73/github" + "github.com/migueleliasweb/go-github-mock/src/mock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_GetDependabotAlert(t *testing.T) { + // Verify tool definition + mockClient := github.NewClient(nil) + tool, _ := GetDependabotAlert(stubGetClientFn(mockClient), translations.NullTranslationHelper) + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + // Validate tool schema + assert.Equal(t, "get_dependabot_alert", 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, "alertNumber") + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "alertNumber"}) + + // Setup mock alert for success case + mockAlert := &github.DependabotAlert{ + Number: github.Ptr(42), + State: github.Ptr("open"), + HTMLURL: github.Ptr("https://github.com/owner/repo/security/dependabot/42"), + } + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]interface{} + expectError bool + expectedAlert *github.DependabotAlert + expectedErrMsg string + }{ + { + name: "successful alert fetch", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatch( + mock.GetReposDependabotAlertsByOwnerByRepoByAlertNumber, + mockAlert, + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "alertNumber": float64(42), + }, + expectError: false, + expectedAlert: mockAlert, + }, + { + name: "alert fetch fails", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposDependabotAlertsByOwnerByRepoByAlertNumber, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message": "Not Found"}`)) + }), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "alertNumber": float64(9999), + }, + expectError: true, + expectedErrMsg: "failed to get alert", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Setup client with mock + client := github.NewClient(tc.mockedClient) + _, handler := GetDependabotAlert(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.NoError(t, err) + require.True(t, result.IsError) + errorContent := getErrorResult(t, result) + assert.Contains(t, errorContent.Text, tc.expectedErrMsg) + return + } + + require.NoError(t, err) + require.False(t, result.IsError) + + // Parse the result and get the text content if no error + textContent := getTextResult(t, result) + + // Unmarshal and verify the result + var returnedAlert github.DependabotAlert + err = json.Unmarshal([]byte(textContent.Text), &returnedAlert) + assert.NoError(t, err) + assert.Equal(t, *tc.expectedAlert.Number, *returnedAlert.Number) + assert.Equal(t, *tc.expectedAlert.State, *returnedAlert.State) + assert.Equal(t, *tc.expectedAlert.HTMLURL, *returnedAlert.HTMLURL) + }) + } +} + +func Test_ListDependabotAlerts(t *testing.T) { + // Verify tool definition once + mockClient := github.NewClient(nil) + tool, _ := ListDependabotAlerts(stubGetClientFn(mockClient), translations.NullTranslationHelper) + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + assert.Equal(t, "list_dependabot_alerts", 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, "state") + assert.Contains(t, tool.InputSchema.Properties, "severity") + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo"}) + + // Setup mock alerts for success case + criticalAlert := github.DependabotAlert{ + Number: github.Ptr(1), + HTMLURL: github.Ptr("https://github.com/owner/repo/security/dependabot/1"), + State: github.Ptr("open"), + SecurityAdvisory: &github.DependabotSecurityAdvisory{ + Severity: github.Ptr("critical"), + }, + } + highSeverityAlert := github.DependabotAlert{ + Number: github.Ptr(2), + HTMLURL: github.Ptr("https://github.com/owner/repo/security/dependabot/2"), + State: github.Ptr("fixed"), + SecurityAdvisory: &github.DependabotSecurityAdvisory{ + Severity: github.Ptr("high"), + }, + } + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]interface{} + expectError bool + expectedAlerts []*github.DependabotAlert + expectedErrMsg string + }{ + { + name: "successful open alerts listing", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposDependabotAlertsByOwnerByRepo, + expectQueryParams(t, map[string]string{ + "state": "open", + }).andThen( + mockResponse(t, http.StatusOK, []*github.DependabotAlert{&criticalAlert}), + ), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "state": "open", + }, + expectError: false, + expectedAlerts: []*github.DependabotAlert{&criticalAlert}, + }, + { + name: "successful severity filtered listing", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposDependabotAlertsByOwnerByRepo, + expectQueryParams(t, map[string]string{ + "severity": "high", + }).andThen( + mockResponse(t, http.StatusOK, []*github.DependabotAlert{&highSeverityAlert}), + ), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "severity": "high", + }, + expectError: false, + expectedAlerts: []*github.DependabotAlert{&highSeverityAlert}, + }, + { + name: "successful all alerts listing", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposDependabotAlertsByOwnerByRepo, + expectQueryParams(t, map[string]string{}).andThen( + mockResponse(t, http.StatusOK, []*github.DependabotAlert{&criticalAlert, &highSeverityAlert}), + ), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + }, + expectError: false, + expectedAlerts: []*github.DependabotAlert{&criticalAlert, &highSeverityAlert}, + }, + { + name: "alerts listing fails", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposDependabotAlertsByOwnerByRepo, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"message": "Unauthorized access"}`)) + }), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + }, + expectError: true, + expectedErrMsg: "failed to list alerts", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + client := github.NewClient(tc.mockedClient) + _, handler := ListDependabotAlerts(stubGetClientFn(client), translations.NullTranslationHelper) + + request := createMCPRequest(tc.requestArgs) + + result, err := handler(context.Background(), request) + + if tc.expectError { + require.NoError(t, err) + require.True(t, result.IsError) + errorContent := getErrorResult(t, result) + assert.Contains(t, errorContent.Text, tc.expectedErrMsg) + return + } + + require.NoError(t, err) + require.False(t, result.IsError) + + textContent := getTextResult(t, result) + + // Unmarshal and verify the result + var returnedAlerts []*github.DependabotAlert + err = json.Unmarshal([]byte(textContent.Text), &returnedAlerts) + assert.NoError(t, err) + assert.Len(t, returnedAlerts, len(tc.expectedAlerts)) + for i, alert := range returnedAlerts { + assert.Equal(t, *tc.expectedAlerts[i].Number, *alert.Number) + assert.Equal(t, *tc.expectedAlerts[i].HTMLURL, *alert.HTMLURL) + assert.Equal(t, *tc.expectedAlerts[i].State, *alert.State) + if tc.expectedAlerts[i].SecurityAdvisory != nil && tc.expectedAlerts[i].SecurityAdvisory.Severity != nil && + alert.SecurityAdvisory != nil && alert.SecurityAdvisory.Severity != nil { + assert.Equal(t, *tc.expectedAlerts[i].SecurityAdvisory.Severity, *alert.SecurityAdvisory.Severity) + } + } + }) + } +} diff --git a/pkg/github/discussions.go b/pkg/github/discussions.go new file mode 100644 index 000000000..fce07ecdb --- /dev/null +++ b/pkg/github/discussions.go @@ -0,0 +1,521 @@ +package github + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/github/github-mcp-server/pkg/translations" + "github.com/go-viper/mapstructure/v2" + "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")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_LIST_DISCUSSIONS_USER_TITLE", "List discussions"), + ReadOnlyHint: ToBoolPtr(true), + }), + mcp.WithString("owner", + mcp.Required(), + mcp.Description("Repository owner"), + ), + mcp.WithString("repo", + mcp.Required(), + mcp.Description("Repository name"), + ), + 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) { + 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 + } + + 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 + } + + var categoryID *githubv4.ID + if category != "" { + id := githubv4.ID(category) + categoryID = &id + } + + 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 { + vars["categoryId"] = *categoryID + } + + discussionQuery := getQueryType(useOrdering, categoryID) + if err := client.Query(ctx, discussionQuery, vars); err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + // 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 + } + + // 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) + } + return mcp.NewToolResultText(string(out)), nil + } +} + +func GetDiscussion(getGQLClient GetGQLClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("get_discussion", + mcp.WithDescription(t("TOOL_GET_DISCUSSION_DESCRIPTION", "Get a specific discussion by ID")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_GET_DISCUSSION_USER_TITLE", "Get discussion"), + ReadOnlyHint: ToBoolPtr(true), + }), + 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"), + ), + ), + func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + // Decode params + var params struct { + Owner string + Repo string + DiscussionNumber int32 + } + if err := mapstructure.Decode(request.Params.Arguments, ¶ms); err != nil { + return mcp.NewToolResultError(err.Error()), 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 { + 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)"` + } + vars := map[string]interface{}{ + "owner": githubv4.String(params.Owner), + "repo": githubv4.String(params.Repo), + "discussionNumber": githubv4.Int(params.DiscussionNumber), + } + if err := client.Query(ctx, &q, vars); err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + d := q.Repository.Discussion + discussion := &github.Discussion{ + Number: github.Ptr(int(d.Number)), + Body: github.Ptr(string(d.Body)), + HTMLURL: github.Ptr(string(d.URL)), + CreatedAt: &github.Timestamp{Time: d.CreatedAt.Time}, + DiscussionCategory: &github.DiscussionCategory{ + Name: github.Ptr(string(d.Category.Name)), + }, + } + out, err := json.Marshal(discussion) + if err != nil { + return nil, fmt.Errorf("failed to marshal discussion: %w", err) + } + + return mcp.NewToolResultText(string(out)), nil + } +} + +func GetDiscussionComments(getGQLClient GetGQLClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("get_discussion_comments", + mcp.WithDescription(t("TOOL_GET_DISCUSSION_COMMENTS_DESCRIPTION", "Get comments from a discussion")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_GET_DISCUSSION_COMMENTS_USER_TITLE", "Get discussion comments"), + ReadOnlyHint: ToBoolPtr(true), + }), + 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 + var params struct { + Owner string + Repo string + DiscussionNumber int32 + } + if err := mapstructure.Decode(request.Params.Arguments, ¶ms); 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 + } + + // 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 + } + + var q struct { + Repository struct { + Discussion struct { + Comments struct { + Nodes []struct { + Body githubv4.String + } + 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)"` + } + vars := map[string]interface{}{ + "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))}) + } + + // 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) + } + + return mcp.NewToolResultText(string(out)), nil + } +} + +func ListDiscussionCategories(getGQLClient GetGQLClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("list_discussion_categories", + mcp.WithDescription(t("TOOL_LIST_DISCUSSION_CATEGORIES_DESCRIPTION", "List discussion categories with their id and name, for a repository")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_LIST_DISCUSSION_CATEGORIES_USER_TITLE", "List discussion categories"), + ReadOnlyHint: ToBoolPtr(true), + }), + mcp.WithString("owner", + mcp.Required(), + mcp.Description("Repository owner"), + ), + mcp.WithString("repo", + mcp.Required(), + mcp.Description("Repository name"), + ), + ), + func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + // Decode params + var params struct { + Owner string + Repo string + } + if err := mapstructure.Decode(request.Params.Arguments, ¶ms); err != nil { + return mcp.NewToolResultError(err.Error()), 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 { + Nodes []struct { + ID githubv4.ID + Name githubv4.String + } + 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{ + "id": fmt.Sprint(c.ID), + "name": string(c.Name), + }) + } + + // 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) + } + return mcp.NewToolResultText(string(out)), nil + } +} diff --git a/pkg/github/discussions_test.go b/pkg/github/discussions_test.go new file mode 100644 index 000000000..aefaf2f8c --- /dev/null +++ b/pkg/github/discussions_test.go @@ -0,0 +1,624 @@ +package github + +import ( + "context" + "encoding/json" + "net/http" + "testing" + "time" + + "github.com/github/github-mcp-server/internal/githubv4mock" + "github.com/github/github-mcp-server/pkg/translations" + "github.com/google/go-github/v73/github" + "github.com/shurcooL/githubv4" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +var ( + discussionsGeneral = []map[string]any{ + {"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", + "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, + "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, + "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") +) + +func Test_ListDiscussions(t *testing.T) { + mockClient := githubv4.NewClient(nil) + 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"}) + + // Variables matching what GraphQL receives after JSON marshaling/unmarshaling + varsListAll := map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "first": float64(30), + "after": (*string)(nil), + } + + varsRepoNotFound := map[string]interface{}{ + "owner": "owner", + "repo": "nonexistent-repo", + "first": float64(30), + "after": (*string)(nil), + } + + varsDiscussionsFiltered := map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "categoryId": "DIC_kwDOABC123", + "first": float64(30), + "after": (*string)(nil), + } + + varsOrderByCreatedAsc := map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "orderByField": "CREATED_AT", + "orderByDirection": "ASC", + "first": float64(30), + "after": (*string)(nil), + } + + varsOrderByUpdatedDesc := map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "orderByField": "UPDATED_AT", + "orderByDirection": "DESC", + "first": float64(30), + "after": (*string)(nil), + } + + varsCategoryWithOrder := map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "categoryId": "DIC_kwDOABC123", + "orderByField": "CREATED_AT", + "orderByDirection": "DESC", + "first": float64(30), + "after": (*string)(nil), + } + + tests := []struct { + name string + reqParams map[string]interface{} + expectError bool + errContains string + expectedCount int + verifyOrder func(t *testing.T, discussions []*github.Discussion) + }{ + { + name: "list all discussions without category filter", + reqParams: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + }, + expectError: false, + expectedCount: 3, // All discussions + }, + { + name: "filter by category ID", + reqParams: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "category": "DIC_kwDOABC123", + }, + 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{}{ + "owner": "owner", + "repo": "nonexistent-repo", + }, + expectError: true, + errContains: "repository not found", + }, + } + + // 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": + matcher := githubv4mock.NewQueryMatcher(qBasicNoOrder, varsListAll, mockResponseListAll) + httpClient = githubv4mock.NewMockedHTTPClient(matcher) + case "filter by category ID": + 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(qBasicNoOrder, varsRepoNotFound, mockErrorRepoNotFound) + httpClient = githubv4mock.NewMockedHTTPClient(matcher) + } + + gqlClient := githubv4.NewClient(httpClient) + _, handler := ListDiscussions(stubGetGQLClientFn(gqlClient), translations.NullTranslationHelper) + + req := createMCPRequest(tc.reqParams) + res, err := handler(context.Background(), req) + text := getTextResult(t, res).Text + + if tc.expectError { + require.True(t, res.IsError) + assert.Contains(t, text, tc.errContains) + return + } + require.NoError(t, err) + + // Parse the structured response with pagination info + var response struct { + 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, 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 response.Discussions { + require.NotNil(t, discussion.DiscussionCategory, "Discussion should have category") + assert.NotEmpty(t, *discussion.DiscussionCategory.Name, "Discussion should have category name") + } + } + }) + } +} + +func Test_GetDiscussion(t *testing.T) { + // Verify tool definition and schema + toolDef, _ := GetDiscussion(nil, translations.NullTranslationHelper) + assert.Equal(t, "get_discussion", 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, "discussionNumber") + assert.ElementsMatch(t, toolDef.InputSchema.Required, []string{"owner", "repo", "discussionNumber"}) + + // 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": "owner", + "repo": "repo", + "discussionNumber": float64(1), + } + tests := []struct { + name string + response githubv4mock.GQLResponse + expectError bool + expected *github.Discussion + errContains string + }{ + { + name: "successful retrieval", + response: githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{"discussion": map[string]any{ + "number": 1, + "body": "This is a test discussion", + "url": "https://github.com/owner/repo/discussions/1", + "createdAt": "2025-04-25T12:00:00Z", + "category": map[string]any{"name": "General"}, + }}, + }), + expectError: false, + expected: &github.Discussion{ + HTMLURL: github.Ptr("https://github.com/owner/repo/discussions/1"), + Number: github.Ptr(1), + Body: github.Ptr("This is a test discussion"), + CreatedAt: &github.Timestamp{Time: time.Date(2025, 4, 25, 12, 0, 0, 0, time.UTC)}, + DiscussionCategory: &github.DiscussionCategory{ + Name: github.Ptr("General"), + }, + }, + }, + { + name: "discussion not found", + response: githubv4mock.ErrorResponse("discussion not found"), + expectError: true, + errContains: "discussion not found", + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + matcher := githubv4mock.NewQueryMatcher(qGetDiscussion, vars, tc.response) + httpClient := githubv4mock.NewMockedHTTPClient(matcher) + gqlClient := githubv4.NewClient(httpClient) + _, handler := GetDiscussion(stubGetGQLClientFn(gqlClient), translations.NullTranslationHelper) + + req := createMCPRequest(map[string]interface{}{"owner": "owner", "repo": "repo", "discussionNumber": int32(1)}) + res, err := handler(context.Background(), req) + text := getTextResult(t, res).Text + + if tc.expectError { + require.True(t, res.IsError) + assert.Contains(t, text, tc.errContains) + return + } + + require.NoError(t, err) + var out github.Discussion + require.NoError(t, json.Unmarshal([]byte(text), &out)) + assert.Equal(t, *tc.expected.HTMLURL, *out.HTMLURL) + assert.Equal(t, *tc.expected.Number, *out.Number) + assert.Equal(t, *tc.expected.Body, *out.Body) + // Check category label + assert.Equal(t, *tc.expected.DiscussionCategory.Name, *out.DiscussionCategory.Name) + }) + } +} + +func Test_GetDiscussionComments(t *testing.T) { + // Verify tool definition and schema + toolDef, _ := GetDiscussionComments(nil, translations.NullTranslationHelper) + assert.Equal(t, "get_discussion_comments", 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, "discussionNumber") + assert.ElementsMatch(t, toolDef.InputSchema.Required, []string{"owner", "repo", "discussionNumber"}) + + // 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": "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{ + "comments": map[string]any{ + "nodes": []map[string]any{ + {"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(qGetComments, vars, mockResponse) + httpClient := githubv4mock.NewMockedHTTPClient(matcher) + gqlClient := githubv4.NewClient(httpClient) + _, handler := GetDiscussionComments(stubGetGQLClientFn(gqlClient), translations.NullTranslationHelper) + + request := createMCPRequest(map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "discussionNumber": int32(1), + }) + + result, err := handler(context.Background(), request) + require.NoError(t, err) + + textContent := getTextResult(t, result) + + // (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, response.Comments, 2) + expectedBodies := []string{"This is the first comment", "This is the second comment"} + for i, comment := range response.Comments { + assert.Equal(t, expectedBodies[i], *comment.Body) + } +} + +func Test_ListDiscussionCategories(t *testing.T) { + // 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": "owner", + "repo": "repo", + "first": float64(25), + } + + mockResp := githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "discussionCategories": map[string]any{ + "nodes": []map[string]any{ + {"id": "123", "name": "CategoryOne"}, + {"id": "456", "name": "CategoryTwo"}, + }, + "pageInfo": map[string]any{ + "hasNextPage": false, + "hasPreviousPage": false, + "startCursor": "", + "endCursor": "", + }, + "totalCount": 2, + }, + }, + }) + matcher := githubv4mock.NewQueryMatcher(qListCategories, vars, mockResp) + httpClient := githubv4mock.NewMockedHTTPClient(matcher) + gqlClient := githubv4.NewClient(httpClient) + + tool, handler := ListDiscussionCategories(stubGetGQLClientFn(gqlClient), translations.NullTranslationHelper) + assert.Equal(t, "list_discussion_categories", tool.Name) + assert.NotEmpty(t, tool.Description) + assert.Contains(t, tool.InputSchema.Properties, "owner") + assert.Contains(t, tool.InputSchema.Properties, "repo") + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo"}) + + request := createMCPRequest(map[string]interface{}{"owner": "owner", "repo": "repo"}) + result, err := handler(context.Background(), request) + require.NoError(t, err) + + text := getTextResult(t, result).Text + + 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 3242c2be9..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", @@ -608,12 +1011,7 @@ func GetIssueComments(getClient GetClientFn, t translations.TranslationHelperFun mcp.Required(), mcp.Description("Issue number"), ), - mcp.WithNumber("page", - mcp.Description("Page number"), - ), - mcp.WithNumber("per_page", - mcp.Description("Number of records per page"), - ), + WithPagination(), ), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { owner, err := RequiredParam[string](request, "owner") @@ -628,19 +1026,15 @@ func GetIssueComments(getClient GetClientFn, t translations.TranslationHelperFun 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) + pagination, err := OptionalPaginationParams(request) if err != nil { return mcp.NewToolResultError(err.Error()), nil } opts := &github.IssueListCommentsOptions{ ListOptions: github.ListOptions{ - Page: page, - PerPage: perPage, + Page: pagination.Page, + PerPage: pagination.PerPage, }, } @@ -893,7 +1287,7 @@ func AssignCodingAgentPrompt(t translations.TranslationHelperFunc) (tool mcp.Pro return mcp.NewPrompt("AssignCodingAgent", mcp.WithPromptDescription(t("PROMPT_ASSIGN_CODING_AGENT_DESCRIPTION", "Assign GitHub Coding Agent to multiple tasks in a GitHub repository.")), mcp.WithArgument("repo", mcp.ArgumentDescription("The repository to assign tasks in (owner/repo)."), mcp.RequiredArgument()), - ), func(ctx context.Context, request mcp.GetPromptRequest) (*mcp.GetPromptResult, error) { + ), func(_ context.Context, request mcp.GetPromptRequest) (*mcp.GetPromptResult, error) { repo := request.Params.Arguments["repo"] messages := []mcp.PromptMessage{ diff --git a/pkg/github/issues_test.go b/pkg/github/issues_test.go index 056fa7ed8..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) }) } } @@ -1087,7 +1093,7 @@ func Test_GetIssueComments(t *testing.T) { 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.Contains(t, tool.InputSchema.Properties, "perPage") assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "issue_number"}) // Setup mock comments for success case @@ -1152,7 +1158,7 @@ func Test_GetIssueComments(t *testing.T) { "repo": "repo", "issue_number": float64(42), "page": float64(2), - "per_page": float64(10), + "perPage": float64(10), }, expectError: false, expectedComments: mockComments, @@ -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 bad822b13..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" @@ -330,7 +330,7 @@ func UpdatePullRequest(getClient GetClientFn, t translations.TranslationHelperFu // ListPullRequests creates a tool to list and filter repository pull requests. func ListPullRequests(getClient GetClientFn, t translations.TranslationHelperFunc) (mcp.Tool, server.ToolHandlerFunc) { return mcp.NewTool("list_pull_requests", - mcp.WithDescription(t("TOOL_LIST_PULL_REQUESTS_DESCRIPTION", "List pull requests in a GitHub repository.")), + mcp.WithDescription(t("TOOL_LIST_PULL_REQUESTS_DESCRIPTION", "List pull requests in a GitHub repository. If the user specifies an author, then DO NOT use this tool and use the search_pull_requests tool instead.")), mcp.WithToolAnnotation(mcp.ToolAnnotation{ Title: t("TOOL_LIST_PULL_REQUESTS_USER_TITLE", "List pull requests"), ReadOnlyHint: ToBoolPtr(true), @@ -396,7 +396,6 @@ func ListPullRequests(getClient GetClientFn, t translations.TranslationHelperFun if err != nil { return mcp.NewToolResultError(err.Error()), nil } - opts := &github.PullRequestListOptions{ State: state, Head: head, @@ -404,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, }, } @@ -623,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 { @@ -1152,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 fa5d7338a..ecd36d7e0 100644 --- a/pkg/github/repositories.go +++ b/pkg/github/repositories.go @@ -8,13 +8,12 @@ import ( "io" "net/http" "net/url" - "strconv" "strings" 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" ) @@ -59,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) @@ -97,7 +96,7 @@ func GetCommit(getClient GetClientFn, t translations.TranslationHelperFunc) (too // ListCommits creates a tool to get commits of a branch in a repository. func ListCommits(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { return mcp.NewTool("list_commits", - mcp.WithDescription(t("TOOL_LIST_COMMITS_DESCRIPTION", "Get list of commits of a branch in a GitHub repository")), + mcp.WithDescription(t("TOOL_LIST_COMMITS_DESCRIPTION", "Get list of commits of a branch in a GitHub repository. Returns at least 30 results per page by default, but can return more if specified using the perPage parameter (up to 100).")), mcp.WithToolAnnotation(mcp.ToolAnnotation{ Title: t("TOOL_LIST_COMMITS_USER_TITLE", "List commits"), ReadOnlyHint: ToBoolPtr(true), @@ -111,10 +110,10 @@ func ListCommits(getClient GetClientFn, t translations.TranslationHelperFunc) (t mcp.Description("Repository name"), ), mcp.WithString("sha", - mcp.Description("SHA or Branch name"), + mcp.Description("Commit SHA, branch or tag name to list commits of. If not provided, uses the default branch of the repository. If a commit SHA is provided, will list commits up to that SHA."), ), mcp.WithString("author", - mcp.Description("Author username or email address"), + mcp.Description("Author username or email address to filter commits by"), ), WithPagination(), ), @@ -139,13 +138,17 @@ func ListCommits(getClient GetClientFn, t translations.TranslationHelperFunc) (t if err != nil { return mcp.NewToolResultError(err.Error()), nil } - + // Set default perPage to 30 if not provided + perPage := pagination.PerPage + if perPage == 0 { + perPage = 30 + } opts := &github.CommitsListOptions{ SHA: sha, Author: author, ListOptions: github.ListOptions{ - Page: pagination.page, - PerPage: pagination.perPage, + Page: pagination.Page, + PerPage: perPage, }, } @@ -214,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, }, } @@ -284,7 +287,7 @@ func CreateOrUpdateFile(getClient GetClientFn, t translations.TranslationHelperF mcp.Description("Branch to create/update the file in"), ), mcp.WithString("sha", - mcp.Description("SHA of file being replaced (for updates)"), + mcp.Description("Required if updating an existing file. The blob SHA of the file being replaced."), ), ), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { @@ -459,14 +462,14 @@ func GetFileContents(getClient GetClientFn, getRawClient raw.GetRawClientFn, t t mcp.Description("Repository name"), ), mcp.WithString("path", - mcp.Required(), mcp.Description("Path to file/directory (directories must end with a slash '/')"), + mcp.DefaultString("/"), ), mcp.WithString("ref", mcp.Description("Accepts optional git refs such as `refs/tags/{tag}`, `refs/heads/{branch}` or `refs/pull/{pr_number}/head`"), ), mcp.WithString("sha", - mcp.Description("Accepts optional git sha, if sha is specified it will be used instead of ref"), + mcp.Description("Accepts optional commit SHA. If specified, it will be used instead of ref"), ), ), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { @@ -491,34 +494,37 @@ func GetFileContents(getClient GetClientFn, getRawClient raw.GetRawClientFn, t t return mcp.NewToolResultError(err.Error()), nil } - rawOpts := &raw.RawContentOpts{} - - if strings.HasPrefix(ref, "refs/pull/") { - prNumber := strings.TrimSuffix(strings.TrimPrefix(ref, "refs/pull/"), "/head") - if len(prNumber) > 0 { - // fetch the PR from the API to get the latest commit and use SHA - githubClient, err := getClient(ctx) - if err != nil { - return nil, fmt.Errorf("failed to get GitHub client: %w", err) - } - prNum, err := strconv.Atoi(prNumber) - if err != nil { - return nil, fmt.Errorf("invalid pull request number: %w", err) - } - pr, _, err := githubClient.PullRequests.Get(ctx, owner, repo, prNum) - if err != nil { - return nil, fmt.Errorf("failed to get pull request: %w", err) - } - sha = pr.GetHead().GetSHA() - ref = "" - } + client, err := getClient(ctx) + if err != nil { + return mcp.NewToolResultError("failed to get GitHub client"), nil } - rawOpts.SHA = sha - rawOpts.Ref = ref + rawOpts, err := resolveGitReference(ctx, client, owner, repo, ref, sha) + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("failed to resolve git reference: %s", err)), nil + } - // 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 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 { @@ -532,9 +538,7 @@ func GetFileContents(getClient GetClientFn, getRawClient raw.GetRawClientFn, t t _ = resp.Body.Close() }() - if resp.StatusCode != http.StatusOK { - // If the raw content is not found, we will fall back to the GitHub API (in case it is a directory) - } else { + if resp.StatusCode == http.StatusOK { // If the raw content is found, return it directly body, err := io.ReadAll(resp.Body) if err != nil { @@ -562,52 +566,77 @@ 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 } } - client, err := getClient(ctx) - if err != nil { - return mcp.NewToolResultError("failed to get GitHub client"), nil - } - - if sha != "" { - ref = sha + if rawOpts.SHA != "" { + ref = rawOpts.SHA } if strings.HasSuffix(path, "/") { opts := &github.RepositoryContentGetOptions{Ref: ref} _, dirContent, resp, err := client.Repositories.GetContents(ctx, owner, repo, path, opts) - if err != nil { - return mcp.NewToolResultError("failed to get file contents"), nil - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != 200 { - body, err := io.ReadAll(resp.Body) + if err == nil && resp.StatusCode == http.StatusOK { + defer func() { _ = resp.Body.Close() }() + r, err := json.Marshal(dirContent) if err != nil { - return mcp.NewToolResultError("failed to read response body"), nil + return mcp.NewToolResultError("failed to marshal response"), nil } - return mcp.NewToolResultError(fmt.Sprintf("failed to get file contents: %s", string(body))), nil + return mcp.NewToolResultText(string(r)), nil } + } + + // The path does not point to a file or directory. + // Instead let's try to find it in the Git Tree by matching the end of the path. - r, err := json.Marshal(dirContent) + // Step 1: Get Git Tree recursively + tree, resp, err := client.Git.GetTree(ctx, owner, repo, ref, true) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + "failed to get git tree", + resp, + err, + ), nil + } + defer func() { _ = resp.Body.Close() }() + + // Step 2: Filter tree for matching paths + const maxMatchingFiles = 3 + matchingFiles := filterPaths(tree.Entries, path, maxMatchingFiles) + if len(matchingFiles) > 0 { + matchingFilesJSON, err := json.Marshal(matchingFiles) + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("failed to marshal matching files: %s", err)), nil + } + resolvedRefs, err := json.Marshal(rawOpts) if err != nil { - return mcp.NewToolResultError("failed to marshal response"), nil + return mcp.NewToolResultError(fmt.Sprintf("failed to marshal resolved refs: %s", err)), nil } - return mcp.NewToolResultText(string(r)), nil + return mcp.NewToolResultText(fmt.Sprintf("Path did not point to a file or directory, but resolved git ref to %s with possible path matches: %s", resolvedRefs, matchingFilesJSON)), nil } + return mcp.NewToolResultError("Failed to get file contents. The path does not point to a file or directory, or the file does not exist in the repository."), nil } } @@ -1169,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) @@ -1291,3 +1320,74 @@ func GetTag(getClient GetClientFn, t translations.TranslationHelperFunc) (tool m return mcp.NewToolResultText(string(r)), nil } } + +// filterPaths filters the entries in a GitHub tree to find paths that +// match the given suffix. +// maxResults limits the number of results returned to first maxResults entries, +// a maxResults of -1 means no limit. +// It returns a slice of strings containing the matching paths. +// Directories are returned with a trailing slash. +func filterPaths(entries []*github.TreeEntry, path string, maxResults int) []string { + // Remove trailing slash for matching purposes, but flag whether we + // only want directories. + dirOnly := false + if strings.HasSuffix(path, "/") { + dirOnly = true + path = strings.TrimSuffix(path, "/") + } + + matchedPaths := []string{} + for _, entry := range entries { + if len(matchedPaths) == maxResults { + break // Limit the number of results to maxResults + } + if dirOnly && entry.GetType() != "tree" { + continue // Skip non-directory entries if dirOnly is true + } + entryPath := entry.GetPath() + if entryPath == "" { + continue // Skip empty paths + } + if strings.HasSuffix(entryPath, path) { + if entry.GetType() == "tree" { + entryPath += "/" // Return directories with a trailing slash + } + matchedPaths = append(matchedPaths, entryPath) + } + } + return matchedPaths +} + +// resolveGitReference resolves git references with the following logic: +// 1. If SHA is provided, it takes precedence +// 2. If neither is provided, use the default branch as ref +// 3. Get commit SHA from the ref +// Refs can look like `refs/tags/{tag}`, `refs/heads/{branch}` or `refs/pull/{pr_number}/head` +// The function returns the resolved ref, commit SHA and any error. +func resolveGitReference(ctx context.Context, githubClient *github.Client, owner, repo, ref, sha string) (*raw.ContentOpts, error) { + // 1. If SHA is provided, use it directly + if sha != "" { + return &raw.ContentOpts{Ref: "", SHA: sha}, nil + } + + // 2. If neither provided, use the default branch as ref + if ref == "" { + repoInfo, resp, err := githubClient.Repositories.Get(ctx, owner, repo) + if err != nil { + _, _ = ghErrors.NewGitHubAPIErrorToCtx(ctx, "failed to get repository info", resp, err) + return nil, fmt.Errorf("failed to get repository info: %w", err) + } + ref = fmt.Sprintf("refs/heads/%s", repoInfo.GetDefaultBranch()) + } + + // 3. Get the SHA from the ref + reference, resp, err := githubClient.Git.GetRef(ctx, owner, repo, ref) + if err != nil { + _, _ = ghErrors.NewGitHubAPIErrorToCtx(ctx, "failed to get reference", resp, err) + return nil, fmt.Errorf("failed to get reference: %w", err) + } + sha = reference.GetObject().GetSHA() + + // Use provided ref, or it will be empty which defaults to the default branch + return &raw.ContentOpts{Ref: ref, SHA: sha}, nil +} diff --git a/pkg/github/repositories_test.go b/pkg/github/repositories_test.go index b621cec43..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" @@ -33,7 +33,7 @@ func Test_GetFileContents(t *testing.T) { assert.Contains(t, tool.InputSchema.Properties, "path") assert.Contains(t, tool.InputSchema.Properties, "ref") assert.Contains(t, tool.InputSchema.Properties, "sha") - assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "path"}) + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo"}) // Mock response for raw content mockRawContent := []byte("# Test Repository\n\nThis is a test repository.") @@ -69,6 +69,27 @@ func Test_GetFileContents(t *testing.T) { { name: "successful text content fetch", mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposGitRefByOwnerByRepoByRef, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"ref": "refs/heads/main", "object": {"sha": ""}}`)) + }), + ), + 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) { @@ -93,6 +114,27 @@ func Test_GetFileContents(t *testing.T) { { name: "successful file blob content fetch", mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposGitRefByOwnerByRepoByRef, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"ref": "refs/heads/main", "object": {"sha": ""}}`)) + }), + ), + 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) { @@ -117,6 +159,20 @@ func Test_GetFileContents(t *testing.T) { { name: "successful directory content fetch", mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposByOwnerByRepo, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"name": "repo", "default_branch": "main"}`)) + }), + ), + mock.WithRequestMatchHandler( + mock.GetReposGitRefByOwnerByRepoByRef, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"ref": "refs/heads/main", "object": {"sha": ""}}`)) + }), + ), mock.WithRequestMatchHandler( mock.GetReposContentsByOwnerByRepoByPath, expectQueryParams(t, map[string]string{}).andThen( @@ -143,6 +199,13 @@ func Test_GetFileContents(t *testing.T) { { name: "content fetch fails", mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposGitRefByOwnerByRepoByRef, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"ref": "refs/heads/main", "object": {"sha": ""}}`)) + }), + ), mock.WithRequestMatchHandler( mock.GetReposContentsByOwnerByRepoByPath, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { @@ -203,7 +266,7 @@ func Test_GetFileContents(t *testing.T) { textContent := getTextResult(t, result) var returnedContents []*github.RepositoryContent err = json.Unmarshal([]byte(textContent.Text), &returnedContents) - require.NoError(t, err) + require.NoError(t, err, "Failed to unmarshal directory content result: %v", textContent.Text) assert.Len(t, returnedContents, len(expected)) for i, content := range returnedContents { assert.Equal(t, *expected[i].Name, *content.Name) @@ -2049,3 +2112,170 @@ func Test_GetTag(t *testing.T) { }) } } + +func Test_filterPaths(t *testing.T) { + tests := []struct { + name string + tree []*github.TreeEntry + path string + maxResults int + expected []string + }{ + { + name: "file name", + tree: []*github.TreeEntry{ + {Path: github.Ptr("folder/foo.txt"), Type: github.Ptr("blob")}, + {Path: github.Ptr("bar.txt"), Type: github.Ptr("blob")}, + {Path: github.Ptr("nested/folder/foo.txt"), Type: github.Ptr("blob")}, + {Path: github.Ptr("nested/folder/baz.txt"), Type: github.Ptr("blob")}, + }, + path: "foo.txt", + maxResults: -1, + expected: []string{"folder/foo.txt", "nested/folder/foo.txt"}, + }, + { + name: "dir name", + tree: []*github.TreeEntry{ + {Path: github.Ptr("folder"), Type: github.Ptr("tree")}, + {Path: github.Ptr("bar.txt"), Type: github.Ptr("blob")}, + {Path: github.Ptr("nested/folder"), Type: github.Ptr("tree")}, + {Path: github.Ptr("nested/folder/baz.txt"), Type: github.Ptr("blob")}, + }, + path: "folder/", + maxResults: -1, + expected: []string{"folder/", "nested/folder/"}, + }, + { + name: "dir and file match", + tree: []*github.TreeEntry{ + {Path: github.Ptr("name"), Type: github.Ptr("tree")}, + {Path: github.Ptr("name"), Type: github.Ptr("blob")}, + }, + path: "name", // No trailing slash can match both files and directories + maxResults: -1, + expected: []string{"name/", "name"}, + }, + { + name: "dir only match", + tree: []*github.TreeEntry{ + {Path: github.Ptr("name"), Type: github.Ptr("tree")}, + {Path: github.Ptr("name"), Type: github.Ptr("blob")}, + }, + path: "name/", // Trialing slash ensures only directories are matched + maxResults: -1, + expected: []string{"name/"}, + }, + { + name: "max results limit 2", + tree: []*github.TreeEntry{ + {Path: github.Ptr("folder"), Type: github.Ptr("tree")}, + {Path: github.Ptr("nested/folder"), Type: github.Ptr("tree")}, + {Path: github.Ptr("nested/nested/folder"), Type: github.Ptr("tree")}, + }, + path: "folder/", + maxResults: 2, + expected: []string{"folder/", "nested/folder/"}, + }, + { + name: "max results limit 1", + tree: []*github.TreeEntry{ + {Path: github.Ptr("folder"), Type: github.Ptr("tree")}, + {Path: github.Ptr("nested/folder"), Type: github.Ptr("tree")}, + {Path: github.Ptr("nested/nested/folder"), Type: github.Ptr("tree")}, + }, + path: "folder/", + maxResults: 1, + expected: []string{"folder/"}, + }, + { + name: "max results limit 0", + tree: []*github.TreeEntry{ + {Path: github.Ptr("folder"), Type: github.Ptr("tree")}, + {Path: github.Ptr("nested/folder"), Type: github.Ptr("tree")}, + {Path: github.Ptr("nested/nested/folder"), Type: github.Ptr("tree")}, + }, + path: "folder/", + maxResults: 0, + expected: []string{}, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + result := filterPaths(tc.tree, tc.path, tc.maxResults) + assert.Equal(t, tc.expected, result) + }) + } +} + +func Test_resolveGitReference(t *testing.T) { + ctx := context.Background() + owner := "owner" + repo := "repo" + mockedClient := mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposByOwnerByRepo, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"name": "repo", "default_branch": "main"}`)) + }), + ), + mock.WithRequestMatchHandler( + mock.GetReposGitRefByOwnerByRepoByRef, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"ref": "refs/heads/main", "object": {"sha": "123sha456"}}`)) + }), + ), + ) + + tests := []struct { + name string + ref string + sha string + expectedOutput *raw.ContentOpts + }{ + { + name: "sha takes precedence over ref", + ref: "refs/heads/main", + sha: "123sha456", + expectedOutput: &raw.ContentOpts{ + SHA: "123sha456", + }, + }, + { + name: "use default branch if ref and sha both empty", + ref: "", + sha: "", + expectedOutput: &raw.ContentOpts{ + Ref: "refs/heads/main", + SHA: "123sha456", + }, + }, + { + name: "get SHA from ref", + ref: "refs/heads/main", + sha: "", + expectedOutput: &raw.ContentOpts{ + Ref: "refs/heads/main", + SHA: "123sha456", + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Setup client with mock + client := github.NewClient(mockedClient) + opts, err := resolveGitReference(ctx, client, owner, repo, tc.ref, tc.sha) + require.NoError(t, err) + + if tc.expectedOutput.SHA != "" { + assert.Equal(t, tc.expectedOutput.SHA, opts.SHA) + } + if tc.expectedOutput.Ref != "" { + assert.Equal(t, tc.expectedOutput.Ref, opts.Ref) + } + }) + } +} diff --git a/pkg/github/repository_resource.go b/pkg/github/repository_resource.go index fd2a04f89..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" ) @@ -89,7 +89,7 @@ func RepositoryResourceContentsHandler(getClient GetClientFn, getRawClient raw.G } opts := &github.RepositoryContentGetOptions{} - rawOpts := &raw.RawContentOpts{} + rawOpts := &raw.ContentOpts{} sha, ok := request.Params.Arguments["sha"].([]string) if ok && len(sha) > 0 { 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 5106b84d8..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, }, } @@ -155,11 +155,13 @@ func SearchCode(getClient GetClientFn, t translations.TranslationHelperFunc) (to } } +// MinimalUser is the output type for user and organization search results. type MinimalUser struct { - Login string `json:"login"` - ID int64 `json:"id,omitempty"` - ProfileURL string `json:"profile_url,omitempty"` - AvatarURL string `json:"avatar_url,omitempty"` + Login string `json:"login"` + ID int64 `json:"id,omitempty"` + ProfileURL string `json:"profile_url,omitempty"` + AvatarURL string `json:"avatar_url,omitempty"` + Details *UserDetails `json:"details,omitempty"` // Optional field for additional user details } type MinimalSearchUsersResult struct { @@ -191,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, }, } @@ -224,15 +226,11 @@ func userOrOrgHandler(accountType string, getClient GetClientFn) server.ToolHand for _, user := range result.Users { if user.Login != nil { - mu := MinimalUser{Login: *user.Login} - if user.ID != nil { - mu.ID = *user.ID - } - if user.HTMLURL != nil { - mu.ProfileURL = *user.HTMLURL - } - if user.AvatarURL != nil { - mu.AvatarURL = *user.AvatarURL + mu := MinimalUser{ + Login: user.GetLogin(), + ID: user.GetID(), + ProfileURL: user.GetHTMLURL(), + AvatarURL: user.GetAvatarURL(), } minimalUsers = append(minimalUsers, mu) } 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 85d078f1b..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,8 +174,8 @@ 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. +// 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) { mcp.WithNumber("page", @@ -191,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 @@ -210,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 76b31d477..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)), ) @@ -103,6 +107,11 @@ func DefaultToolsetGroup(readOnly bool, getClient GetClientFn, getGQLClient GetG toolsets.NewServerTool(GetSecretScanningAlert(getClient, t)), toolsets.NewServerTool(ListSecretScanningAlerts(getClient, t)), ) + dependabot := toolsets.NewToolset("dependabot", "Dependabot tools"). + AddReadTools( + toolsets.NewServerTool(GetDependabotAlert(getClient, t)), + toolsets.NewServerTool(ListDependabotAlerts(getClient, t)), + ) notifications := toolsets.NewToolset("notifications", "GitHub Notifications related tools"). AddReadTools( @@ -116,6 +125,14 @@ func DefaultToolsetGroup(readOnly bool, getClient GetClientFn, getGQLClient GetG toolsets.NewServerTool(ManageRepositoryNotificationSubscription(getClient, t)), ) + discussions := toolsets.NewToolset("discussions", "GitHub Discussions related tools"). + AddReadTools( + toolsets.NewServerTool(ListDiscussions(getGQLClient, t)), + toolsets.NewServerTool(GetDiscussion(getGQLClient, t)), + toolsets.NewServerTool(GetDiscussionComments(getGQLClient, t)), + toolsets.NewServerTool(ListDiscussionCategories(getGQLClient, t)), + ) + actions := toolsets.NewToolset("actions", "GitHub Actions workflows and CI/CD operations"). AddReadTools( toolsets.NewServerTool(ListWorkflows(getClient, t)), @@ -154,8 +171,10 @@ func DefaultToolsetGroup(readOnly bool, getClient GetClientFn, getGQLClient GetG tsg.AddToolset(actions) tsg.AddToolset(codeSecurity) tsg.AddToolset(secretProtection) + tsg.AddToolset(dependabot) tsg.AddToolset(notifications) tsg.AddToolset(experiments) + tsg.AddToolset(discussions) return tsg } @@ -179,3 +198,12 @@ func InitDynamicToolset(s *server.MCPServer, tsg *toolsets.ToolsetGroup, t trans func ToBoolPtr(b bool) *bool { return &b } + +// ToStringPtr converts a string to a *string pointer. +// Returns nil if the string is empty. +func ToStringPtr(s string) *string { + if s == "" { + return nil + } + return &s +} diff --git a/pkg/raw/raw.go b/pkg/raw/raw.go index e6bab049d..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. @@ -41,9 +41,9 @@ func (c *Client) refURL(owner, repo, ref, path string) string { return c.url.JoinPath(owner, repo, ref, path).String() } -func (c *Client) URLFromOpts(opts *RawContentOpts, owner, repo, path string) string { +func (c *Client) URLFromOpts(opts *ContentOpts, owner, repo, path string) string { if opts == nil { - opts = &RawContentOpts{} + opts = &ContentOpts{} } if opts.SHA != "" { return c.commitURL(owner, repo, opts.SHA, path) @@ -56,13 +56,13 @@ func (c *Client) commitURL(owner, repo, sha, path string) string { return c.url.JoinPath(owner, repo, sha, path).String() } -type RawContentOpts struct { +type ContentOpts struct { Ref string SHA string } // GetRawContent fetches the raw content of a file from a GitHub repository. -func (c *Client) GetRawContent(ctx context.Context, owner, repo, path string, opts *RawContentOpts) (*http.Response, error) { +func (c *Client) GetRawContent(ctx context.Context, owner, repo, path string, opts *ContentOpts) (*http.Response, error) { url := c.URLFromOpts(opts, owner, repo, path) req, err := c.newRequest(ctx, "GET", url, nil) if err != nil { diff --git a/pkg/raw/raw_test.go b/pkg/raw/raw_test.go index bb9b23a28..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" ) @@ -17,7 +17,7 @@ func TestGetRawContent(t *testing.T) { tests := []struct { name string pattern mock.EndpointPattern - opts *RawContentOpts + opts *ContentOpts owner, repo, path string statusCode int contentType string @@ -36,7 +36,7 @@ func TestGetRawContent(t *testing.T) { { name: "branch fetch success", pattern: GetRawReposContentsByOwnerByRepoByBranchByPath, - opts: &RawContentOpts{Ref: "refs/heads/main"}, + opts: &ContentOpts{Ref: "refs/heads/main"}, owner: "octocat", repo: "hello", path: "README.md", statusCode: 200, contentType: "text/plain", @@ -45,7 +45,7 @@ func TestGetRawContent(t *testing.T) { { name: "tag fetch success", pattern: GetRawReposContentsByOwnerByRepoByTagByPath, - opts: &RawContentOpts{Ref: "refs/tags/v1.0.0"}, + opts: &ContentOpts{Ref: "refs/tags/v1.0.0"}, owner: "octocat", repo: "hello", path: "README.md", statusCode: 200, contentType: "text/plain", @@ -54,7 +54,7 @@ func TestGetRawContent(t *testing.T) { { name: "sha fetch success", pattern: GetRawReposContentsByOwnerByRepoBySHAByPath, - opts: &RawContentOpts{SHA: "abc123"}, + opts: &ContentOpts{SHA: "abc123"}, owner: "octocat", repo: "hello", path: "README.md", statusCode: 200, contentType: "text/plain", @@ -107,7 +107,7 @@ func TestUrlFromOpts(t *testing.T) { tests := []struct { name string - opts *RawContentOpts + opts *ContentOpts owner string repo string path string @@ -121,19 +121,19 @@ func TestUrlFromOpts(t *testing.T) { }, { name: "ref branch", - opts: &RawContentOpts{Ref: "refs/heads/main"}, + opts: &ContentOpts{Ref: "refs/heads/main"}, owner: "octocat", repo: "hello", path: "README.md", want: "https://raw.example.com/octocat/hello/refs/heads/main/README.md", }, { name: "ref tag", - opts: &RawContentOpts{Ref: "refs/tags/v1.0.0"}, + opts: &ContentOpts{Ref: "refs/tags/v1.0.0"}, owner: "octocat", repo: "hello", path: "README.md", want: "https://raw.example.com/octocat/hello/refs/tags/v1.0.0/README.md", }, { name: "sha", - opts: &RawContentOpts{SHA: "abc123"}, + opts: &ContentOpts{SHA: "abc123"}, owner: "octocat", repo: "hello", path: "README.md", want: "https://raw.example.com/octocat/hello/abc123/README.md", }, diff --git a/script/generate-docs b/script/generate-docs new file mode 100755 index 000000000..a2a7255d7 --- /dev/null +++ b/script/generate-docs @@ -0,0 +1,5 @@ +#!/bin/bash + +# This script generates documentation for the GitHub MCP server. +# It needs to be run after tool updates to ensure the latest changes are reflected in the documentation. +go run ./cmd/github-mcp-server generate-docs \ No newline at end of file diff --git a/script/get-discussions b/script/get-discussions new file mode 100755 index 000000000..3e68abf24 --- /dev/null +++ b/script/get-discussions @@ -0,0 +1,5 @@ +#!/bin/bash + +# echo '{"jsonrpc":"2.0","id":3,"params":{"name":"list_discussions","arguments": {"owner": "github", "repo": "securitylab", "first": 10, "since": "2025-04-01T00:00:00Z"}},"method":"tools/call"}' | go run cmd/github-mcp-server/main.go stdio | jq . +echo '{"jsonrpc":"2.0","id":3,"params":{"name":"list_discussions","arguments": {"owner": "github", "repo": "securitylab", "first": 10, "since": "2025-04-01T00:00:00Z", "sort": "CREATED_AT", "direction": "DESC"}},"method":"tools/call"}' | go run cmd/github-mcp-server/main.go stdio | jq . + diff --git a/script/lint b/script/lint new file mode 100755 index 000000000..e6ea9da89 --- /dev/null +++ b/script/lint @@ -0,0 +1,14 @@ +set -eu + +# first run go fmt +gofmt -s -w . + +BINDIR="$(git rev-parse --show-toplevel)"/bin +BINARY=$BINDIR/golangci-lint +GOLANGCI_LINT_VERSION=v2.2.1 + +if [ ! -f "$BINARY" ]; then + curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s "$GOLANGCI_LINT_VERSION" +fi + +$BINARY run \ No newline at end of file diff --git a/script/tag-release b/script/tag-release new file mode 100755 index 000000000..fd94167c3 --- /dev/null +++ b/script/tag-release @@ -0,0 +1,151 @@ +#!/bin/bash + +# Exit immediately if a command exits with a non-zero status. +set -e + +# Initialize variables +TAG="" +DRY_RUN=false + +# Parse arguments +for arg in "$@"; do + case $arg in + --dry-run) + DRY_RUN=true + ;; + *) + # The first non-flag argument is the tag + if [[ ! $arg == --* ]]; then + if [ -z "$TAG" ]; then + TAG=$arg + fi + fi + ;; + esac +done + +if [ "$DRY_RUN" = true ]; then + echo "DRY RUN: No changes will be pushed to the remote repository." + echo +fi + +# 1. Validate input +if [ -z "$TAG" ]; then + echo "Error: No tag specified." + echo "Usage: ./script/tag-release vX.Y.Z [--dry-run]" + exit 1 +fi + +# Regular expression for semantic versioning (vX.Y.Z or vX.Y.Z-suffix) +if [[ ! $TAG =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-.*)?$ ]]; then + echo "Error: Tag must be in format vX.Y.Z or vX.Y.Z-suffix (e.g., v1.0.0 or v1.0.0-rc1)" + exit 1 +fi + +# 2. Check current branch +CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD) +if [ "$CURRENT_BRANCH" != "main" ]; then + echo "Error: You must be on the 'main' branch to create a release." + echo "Current branch is '$CURRENT_BRANCH'." + exit 1 +fi + +# 3. Fetch latest from origin +echo "Fetching latest changes from origin..." +git fetch origin main + +# 4. Check if the working directory is clean +if ! git diff-index --quiet HEAD --; then + echo "Error: Working directory is not clean. Please commit or stash your changes." + exit 1 +fi + +# 5. Check if main is up-to-date with origin/main +LOCAL_SHA=$(git rev-parse @) +REMOTE_SHA=$(git rev-parse @{u}) + +if [ "$LOCAL_SHA" != "$REMOTE_SHA" ]; then + echo "Error: Your local 'main' branch is not up-to-date with 'origin/main'. Please pull the latest changes." + exit 1 +fi +echo "✅ Local 'main' branch is up-to-date with 'origin/main'." + +# 6. Check if tag already exists +if git tag -l | grep -q "^${TAG}$"; then + echo "Error: Tag ${TAG} already exists locally." + exit 1 +fi +if git ls-remote --tags origin | grep -q "refs/tags/${TAG}$"; then + echo "Error: Tag ${TAG} already exists on remote 'origin'." + exit 1 +fi + +# 7. Confirm release with user +echo +LATEST_TAG=$(git tag --sort=-version:refname | head -n 1) +if [ -n "$LATEST_TAG" ]; then + echo "Current latest release: $LATEST_TAG" +fi +echo "Proposed new release: $TAG" +echo +read -p "Do you want to proceed with the release? (y/n) " -n 1 -r +echo # Move to a new line +if [[ ! $REPLY =~ ^[Yy]$ ]]; then + echo "Release cancelled." + exit 1 +fi +echo + +# 8. Create the new release tag +if [ "$DRY_RUN" = true ]; then + echo "DRY RUN: Skipping creation of tag $TAG." +else + echo "Creating new release tag: $TAG" + git tag -a "$TAG" -m "Release $TAG" +fi + +# 9. Push the new tag to the remote repository +if [ "$DRY_RUN" = true ]; then + echo "DRY RUN: Skipping push of tag $TAG to origin." +else + echo "Pushing tag $TAG to origin..." + git push origin "$TAG" +fi + +# 10. Update and push the 'latest-release' tag +if [ "$DRY_RUN" = true ]; then + echo "DRY RUN: Skipping update and push of 'latest-release' tag." +else + echo "Updating 'latest-release' tag to point to $TAG..." + git tag -f latest-release "$TAG" + echo "Pushing 'latest-release' tag to origin..." + git push origin latest-release --force +fi + +if [ "$DRY_RUN" = true ]; then + echo "✅ DRY RUN complete. No tags were created or pushed." +else + echo "✅ Successfully tagged and pushed release $TAG." + echo "✅ 'latest-release' tag has been updated." +fi + +# 11. Post-release instructions +REPO_URL=$(git remote get-url origin) +REPO_SLUG=$(echo "$REPO_URL" | sed -e 's/.*github.com[:\/]//' -e 's/\.git$//') + +cat << EOF + +## 🎉 Release $TAG has been initiated! + +### Next steps: +1. 📋 Check https://github.com/$REPO_SLUG/releases and wait for the draft release to show up (after the goreleaser workflow completes) +2. ✏️ Edit the new release, delete the existing notes and click the auto-generate button GitHub provides +3. ✨ Add a section at the top calling out the main features +4. 🚀 Publish the release +5. 📢 Post message in #gh-mcp-releases channel in Slack and then share to the other mcp channels + +### Resources: +- 📦 Draft Release: https://github.com/$REPO_SLUG/releases/tag/$TAG + +The release process is now ready for your review and completion! +EOF diff --git a/script/test b/script/test new file mode 100755 index 000000000..7f0dd0c20 --- /dev/null +++ b/script/test @@ -0,0 +1,3 @@ +set -eu + +go test -race ./... \ No newline at end of file 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