diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 000000000..8f302e7c0
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,11 @@
+.github
+.vscode
+script
+third-party
+.dockerignore
+.gitignore
+**/*.yml
+**/*.yaml
+**/*.md
+**/*_test.go
+LICENSE
diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml
index a37813e3b..9fa416abd 100644
--- a/.github/workflows/lint.yaml
+++ b/.github/workflows/lint.yaml
@@ -24,11 +24,6 @@ jobs:
go mod verify
go mod download
- LINT_VERSION=1.64.8
- curl -fsSL https://github.com/golangci/golangci-lint/releases/download/v${LINT_VERSION}/golangci-lint-${LINT_VERSION}-linux-amd64.tar.gz | \
- tar xz --strip-components 1 --wildcards \*/golangci-lint
- mkdir -p bin && mv golangci-lint bin/
-
- name: Run checks
run: |
STATUS=0
@@ -41,10 +36,10 @@ jobs:
STATUS=1
fi
}
-
- assert-nothing-changed go fmt ./...
assert-nothing-changed go mod tidy
-
- bin/golangci-lint run --out-format=colored-line-number --timeout=3m || STATUS=$?
-
exit $STATUS
+
+ - name: golangci-lint
+ uses: golangci/golangci-lint-action@4afd733a84b1f43292c63897423277bb7f4313a9
+ with:
+ version: v2.1.6
diff --git a/.gitignore b/.gitignore
index 9371be3ed..12649366d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,11 +2,12 @@
cmd/github-mcp-server/github-mcp-server
# VSCode
-.vscode/mcp.json
+.vscode/*
+!.vscode/launch.json
# Added by goreleaser init:
dist/
__debug_bin*
-# Go
+# Go
vendor
diff --git a/.golangci.yml b/.golangci.yml
index 43e3d62dc..61302f6f7 100644
--- a/.golangci.yml
+++ b/.golangci.yml
@@ -1,3 +1,6 @@
+# https://golangci-lint.run/usage/configuration
+version: "2"
+
run:
timeout: 5m
tests: true
@@ -8,21 +11,37 @@ linters:
- govet
- errcheck
- staticcheck
- - gofmt
- - goimports
- revive
- ineffassign
- - typecheck
- unused
- - gosimple
- misspell
- nakedret
- bodyclose
- gocritic
- makezero
- gosec
+ 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
+
+formatters:
+ enable:
+ - gofmt
+ - goimports
output:
- formats: colored-line-number
- print-issued-lines: true
- print-linter-name: true
+ formats:
+ text:
+ print-linter-name: true
+ print-issued-lines: true
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index fe307d1d2..11d63a389 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -15,7 +15,7 @@ Please note that this project is released with a [Contributor Code of Conduct](C
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](https://golangci-lint.run/welcome/install/#local-installation)
+1. [install golangci-lint v2](https://golangci-lint.run/welcome/install/#local-installation)
## Submitting a pull request
diff --git a/Dockerfile b/Dockerfile
index 05fe1ddd2..1281db4c0 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,27 +1,28 @@
+FROM golang:1.24.3-alpine AS build
ARG VERSION="dev"
-FROM golang:1.23.7 AS build
-# allow this step access to build arg
-ARG VERSION
# Set the working directory
WORKDIR /build
-RUN go env -w GOMODCACHE=/root/.cache/go-build
+# Install git
+RUN --mount=type=cache,target=/var/cache/apk \
+ apk add git
-# Install dependencies
-COPY go.mod go.sum ./
-RUN --mount=type=cache,target=/root/.cache/go-build go mod download
-
-COPY . ./
# Build the server
-RUN --mount=type=cache,target=/root/.cache/go-build CGO_ENABLED=0 go build -ldflags="-s -w -X main.version=${VERSION} -X main.commit=$(git rev-parse HEAD) -X main.date=$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
- -o github-mcp-server cmd/github-mcp-server/main.go
+# go build automatically download required module dependencies to /go/pkg/mod
+RUN --mount=type=cache,target=/go/pkg/mod \
+ --mount=type=cache,target=/root/.cache/go-build \
+ --mount=type=bind,target=. \
+ CGO_ENABLED=0 go build -ldflags="-s -w -X main.version=${VERSION} -X main.commit=$(git rev-parse HEAD) -X main.date=$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
+ -o /bin/github-mcp-server cmd/github-mcp-server/main.go
# Make a stage to run the app
FROM gcr.io/distroless/base-debian12
# Set the working directory
WORKDIR /server
# Copy the binary from the build stage
-COPY --from=build /build/github-mcp-server .
-# Command to run the server
-CMD ["./github-mcp-server", "stdio"]
+COPY --from=build /bin/github-mcp-server .
+# Set the entrypoint to the server binary
+ENTRYPOINT ["/server/github-mcp-server"]
+# Default arguments for ENTRYPOINT
+CMD ["stdio"]
diff --git a/README.md b/README.md
index 5977763b9..d40d8aab3 100644
--- a/README.md
+++ b/README.md
@@ -4,18 +4,132 @@ The GitHub MCP Server is a [Model Context Protocol (MCP)](https://modelcontextpr
server that provides seamless integration with GitHub APIs, enabling advanced
automation and interaction capabilities for developers and tools.
-[](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) [](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)
-
-## Use Cases
+### 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.
+---
+
+## Remote GitHub MCP Server
+
+[](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) [](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&quality=insiders)
+
+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/).
+
+## Installation
+
+### Usage with 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:
+
+
+Using OAuth | Using a GitHub PAT |
+VS Code (version 1.101 or greater) |
+
+
+
+```json
+{
+ "servers": {
+ "github-remote": {
+ "type": "http",
+ "url": "https://api.githubcopilot.com/mcp/"
+ }
+ }
+}
+```
+
+ |
+
+
+```json
+{
+ "servers": {
+ "github-remote": {
+ "type": "http",
+ "url": "https://api.githubcopilot.com/mcp/",
+ "headers": {
+ "Authorization": "Bearer ${input:github_mcp_pat}",
+ }
+ }
+ },
+ "inputs": [
+ {
+ "type": "promptString",
+ "id": "github_mcp_pat",
+ "description": "GitHub Personal Access Token",
+ "password": true
+ }
+ ]
+}
+```
+
+ |
+
+
+
+### 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 OAuth | Using a GitHub PAT |
+
+
+
+```json
+{
+ "mcpServers": {
+ "github-remote": {
+ "url": "https://api.githubcopilot.com/mcp/"
+ }
+ }
+}
+```
+
+ |
+
+
+```json
+{
+ "mcpServers": {
+ "github-remote": {
+ "url": "https://api.githubcopilot.com/mcp/",
+ "authorization_token": "Bearer "
+ }
+ }
+}
+```
+
+ |
+
+
+
+> **Note:** The exact configuration format may vary by host. Refer to your host's documentation for the correct syntax and location for remote MCP server setup.
+
+### Configuration
+
+See [Remote Server Documentation](docs/remote-server.md) on how to pass additional configuration settings to the remote GitHub MCP Server.
+
+---
+
+## Local GitHub MCP Server
+
+[](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) [](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
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.
+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)).
@@ -23,13 +137,11 @@ The MCP server can use many of the GitHub APIs, so enable the permissions that y
### Usage with VS Code
-For quick installation, use one of the one-click install buttons at the top of this README. Once you complete that flow, toggle Agent mode (located by the Copilot Chat text input) and the server will start.
-
-For manual installation, add the following JSON block to your User Settings (JSON) file in VS Code. You can do this by pressing `Ctrl + Shift + P` and typing `Preferences: Open User Settings (JSON)`.
+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.
-Optionally, you can add it to a file called `.vscode/mcp.json` in your workspace. This will allow you to share the configuration with others.
+### Usage in other MCP Hosts
-> Note that the `mcp` key is not needed in the `.vscode/mcp.json` file.
+Add the following JSON block to your IDE MCP settings.
```json
{
@@ -62,6 +174,39 @@ Optionally, you can add it to a file called `.vscode/mcp.json` in your workspace
}
```
+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.
+
+
+```json
+{
+ "inputs": [
+ {
+ "type": "promptString",
+ "id": "github_token",
+ "description": "GitHub Personal Access Token",
+ "password": true
+ }
+ ],
+ "servers": {
+ "github": {
+ "command": "docker",
+ "args": [
+ "run",
+ "-i",
+ "--rm",
+ "-e",
+ "GITHUB_PERSONAL_ACCESS_TOKEN",
+ "ghcr.io/github/github-mcp-server"
+ ],
+ "env": {
+ "GITHUB_PERSONAL_ACCESS_TOKEN": "${input:github_token}"
+ }
+ }
+ }
+}
+
+```
+
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
@@ -112,19 +257,25 @@ If you don't have Docker, you can use `go build` to build the binary in the
The GitHub MCP Server supports enabling or disabling specific groups of functionalities via the `--toolsets` flag. This allows you to control which GitHub API capabilities are available to your AI tools. Enabling only the toolsets that you need can help the LLM with tool choice and reduce the context size.
+_Toolsets are not limited to Tools. Relevant MCP Resources and Prompts are also included where applicable._
+
### Available Toolsets
The following sets of tools are available (all are on by default):
| Toolset | Description |
| ----------------------- | ------------------------------------------------------------- |
-| `repos` | Repository-related tools (file operations, branches, commits) |
+| `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) |
-| `users` | Anything relating to GitHub Users |
+| `notifications` | GitHub Notifications related tools |
| `pull_requests` | Pull request operations (create, merge, review) |
-| `code_security` | Code scanning alerts and security features |
+| `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:
@@ -171,7 +322,7 @@ GITHUB_TOOLSETS="all" ./github-mcp-server
**Note**: This feature is currently in beta and may not be available in all environments. Please test it out and let us know if you encounter any issues.
-Instead of starting with all tools enabled, you can turn on dynamic toolset discovery. Dynamic toolsets allow the MCP host to list and enable toolsets in response to a user prompt. This should help to avoid situations where the model gets confused by the shear number of tools available.
+Instead of starting with all tools enabled, you can turn on dynamic toolset discovery. Dynamic toolsets allow the MCP host to list and enable toolsets in response to a user prompt. This should help to avoid situations where the model gets confused by the sheer number of tools available.
### Using Dynamic Tool Discovery
@@ -190,10 +341,49 @@ docker run -i --rm \
ghcr.io/github/github-mcp-server
```
-## GitHub Enterprise Server
+## Read-Only Mode
+
+To run the server in read-only mode, you can use the `--read-only` flag. This will only offer read-only tools, preventing any modifications to repositories, issues, pull requests, etc.
+
+```bash
+./github-mcp-server --read-only
+```
+
+When using Docker, you can pass the read-only mode as an environment variable:
+
+```bash
+docker run -i --rm \
+ -e GITHUB_PERSONAL_ACCESS_TOKEN= \
+ -e GITHUB_READ_ONLY=1 \
+ ghcr.io/github/github-mcp-server
+```
+
+## GitHub Enterprise Server and Enterprise Cloud with data residency (ghe.com)
The flag `--gh-host` and the environment variable `GITHUB_HOST` can be used to set
-the GitHub Enterprise Server hostname.
+the hostname for GitHub Enterprise Server or GitHub Enterprise Cloud with data residency.
+
+- For GitHub Enterprise Server, prefix the hostname with the `https://` URI scheme, as it otherwise defaults to `http://`, which GitHub Enterprise Server does not support.
+- For GitHub Enterprise Cloud with data residency, use `https://YOURSUBDOMAIN.ghe.com` as the hostname.
+``` json
+"github": {
+ "command": "docker",
+ "args": [
+ "run",
+ "-i",
+ "--rm",
+ "-e",
+ "GITHUB_PERSONAL_ACCESS_TOKEN",
+ "-e",
+ "GITHUB_HOST",
+ "ghcr.io/github/github-mcp-server"
+ ],
+ "env": {
+ "GITHUB_PERSONAL_ACCESS_TOKEN": "${input:github_token}",
+ "GITHUB_HOST": "https://"
+ }
+}
+```
## i18n / Overriding Descriptions
@@ -408,6 +598,13 @@ export GITHUB_MCP_TOOL_ADD_ISSUE_COMMENT_DESCRIPTION="an alternative description
- `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)
+
+ - `owner`: Repository owner (string, required)
+ - `repo`: Repository name (string, required)
+ - `pullNumber`: Pull request number (number, required)
+ - _Note_: Currently, this tool will only work for github.com
+
### Repositories
- **create_or_update_file** - Create or update a single file in a repository
@@ -477,7 +674,7 @@ export GITHUB_MCP_TOOL_ADD_ISSUE_COMMENT_DESCRIPTION="an alternative description
- `page`: Page number, for files in the commit (number, optional)
- `perPage`: Results per page, for files in the commit (number, optional)
- - **search_code** - Search for code across GitHub repositories
+- **search_code** - Search for code across GitHub repositories
- `query`: Search query (string, required)
- `sort`: Sort field (string, optional)
- `order`: Sort order (string, optional)
@@ -487,7 +684,7 @@ export GITHUB_MCP_TOOL_ADD_ISSUE_COMMENT_DESCRIPTION="an alternative description
### Users
- **search_users** - Search for GitHub users
- - `query`: Search query (string, required)
+ - `q`: Search query (string, required)
- `sort`: Sort field (string, optional)
- `order`: Sort order (string, optional)
- `page`: Page number (number, optional)
@@ -524,6 +721,39 @@ export GITHUB_MCP_TOOL_ADD_ISSUE_COMMENT_DESCRIPTION="an alternative description
- `secret_type`: The secret types to be filtered for in a comma-separated list (string, optional)
- `resolution`: The resolution status (string, optional)
+### Notifications
+
+- **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)
+
+
+- **get_notification_details** – Get detailed information for a specific GitHub notification
+ - `notificationID`: The ID of the notification (string, required)
+
+- **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`)
+
+- **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)
+
+- **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)
+
+- **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
diff --git a/cmd/github-mcp-server/main.go b/cmd/github-mcp-server/main.go
index 5ca0e21cd..fb716f78d 100644
--- a/cmd/github-mcp-server/main.go
+++ b/cmd/github-mcp-server/main.go
@@ -1,25 +1,17 @@
package main
import (
- "context"
+ "errors"
"fmt"
- "io"
- stdlog "log"
"os"
- "os/signal"
- "syscall"
+ "github.com/github/github-mcp-server/internal/ghmcp"
"github.com/github/github-mcp-server/pkg/github"
- iolog "github.com/github/github-mcp-server/pkg/log"
- "github.com/github/github-mcp-server/pkg/translations"
- gogithub "github.com/google/go-github/v69/github"
- "github.com/mark3labs/mcp-go/mcp"
- "github.com/mark3labs/mcp-go/server"
- log "github.com/sirupsen/logrus"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
+// These variables are set by the build process using ldflags.
var version = "version"
var commit = "commit"
var date = "date"
@@ -36,28 +28,34 @@ var (
Use: "stdio",
Short: "Start stdio server",
Long: `Start a server that communicates via standard input/output streams using JSON-RPC messages.`,
- Run: func(_ *cobra.Command, _ []string) {
- logFile := viper.GetString("log-file")
- readOnly := viper.GetBool("read-only")
- exportTranslations := viper.GetBool("export-translations")
- logger, err := initLogger(logFile)
- if err != nil {
- stdlog.Fatal("Failed to initialize logger:", err)
+ RunE: func(_ *cobra.Command, _ []string) error {
+ token := viper.GetString("personal_access_token")
+ if token == "" {
+ return errors.New("GITHUB_PERSONAL_ACCESS_TOKEN not set")
}
- enabledToolsets := viper.GetStringSlice("toolsets")
-
- logCommands := viper.GetBool("enable-command-logging")
- cfg := runConfig{
- readOnly: readOnly,
- logger: logger,
- logCommands: logCommands,
- exportTranslations: exportTranslations,
- enabledToolsets: enabledToolsets,
+ // If you're wondering why we're not using viper.GetStringSlice("toolsets"),
+ // it's because viper doesn't handle comma-separated values correctly for env
+ // vars when using GetStringSlice.
+ // https://github.com/spf13/viper/issues/380
+ var enabledToolsets []string
+ if err := viper.UnmarshalKey("toolsets", &enabledToolsets); err != nil {
+ return fmt.Errorf("failed to unmarshal toolsets: %w", err)
}
- if err := runStdioServer(cfg); err != nil {
- stdlog.Fatal("failed to run stdio server:", err)
+
+ stdioServerConfig := ghmcp.StdioServerConfig{
+ Version: version,
+ Host: viper.GetString("host"),
+ Token: token,
+ EnabledToolsets: enabledToolsets,
+ DynamicToolsets: viper.GetBool("dynamic_toolsets"),
+ ReadOnly: viper.GetBool("read-only"),
+ ExportTranslations: viper.GetBool("export-translations"),
+ EnableCommandLogging: viper.GetBool("enable-command-logging"),
+ LogFilePath: viper.GetString("log-file"),
}
+
+ return ghmcp.RunStdioServer(stdioServerConfig)
},
}
)
@@ -95,143 +93,9 @@ func initConfig() {
viper.AutomaticEnv()
}
-func initLogger(outPath string) (*log.Logger, error) {
- if outPath == "" {
- return log.New(), nil
- }
-
- file, err := os.OpenFile(outPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666)
- if err != nil {
- return nil, fmt.Errorf("failed to open log file: %w", err)
- }
-
- logger := log.New()
- logger.SetLevel(log.DebugLevel)
- logger.SetOutput(file)
-
- return logger, nil
-}
-
-type runConfig struct {
- readOnly bool
- logger *log.Logger
- logCommands bool
- exportTranslations bool
- enabledToolsets []string
-}
-
-func runStdioServer(cfg runConfig) error {
- // Create app context
- ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
- defer stop()
-
- // Create GH client
- token := viper.GetString("personal_access_token")
- if token == "" {
- cfg.logger.Fatal("GITHUB_PERSONAL_ACCESS_TOKEN not set")
- }
- ghClient := gogithub.NewClient(nil).WithAuthToken(token)
- ghClient.UserAgent = fmt.Sprintf("github-mcp-server/%s", version)
-
- host := viper.GetString("host")
-
- if host != "" {
- var err error
- ghClient, err = ghClient.WithEnterpriseURLs(host, host)
- if err != nil {
- return fmt.Errorf("failed to create GitHub client with host: %w", err)
- }
- }
-
- t, dumpTranslations := translations.TranslationHelper()
-
- beforeInit := func(_ context.Context, _ any, message *mcp.InitializeRequest) {
- ghClient.UserAgent = fmt.Sprintf("github-mcp-server/%s (%s/%s)", version, message.Params.ClientInfo.Name, message.Params.ClientInfo.Version)
- }
-
- getClient := func(_ context.Context) (*gogithub.Client, error) {
- return ghClient, nil // closing over client
- }
-
- hooks := &server.Hooks{
- OnBeforeInitialize: []server.OnBeforeInitializeFunc{beforeInit},
- }
- // Create server
- ghServer := github.NewServer(version, server.WithHooks(hooks))
-
- enabled := cfg.enabledToolsets
- dynamic := viper.GetBool("dynamic_toolsets")
- if dynamic {
- // filter "all" from the enabled toolsets
- enabled = make([]string, 0, len(cfg.enabledToolsets))
- for _, toolset := range cfg.enabledToolsets {
- if toolset != "all" {
- enabled = append(enabled, toolset)
- }
- }
- }
-
- // Create default toolsets
- toolsets, err := github.InitToolsets(enabled, cfg.readOnly, getClient, t)
- context := github.InitContextToolset(getClient, t)
-
- if err != nil {
- stdlog.Fatal("Failed to initialize toolsets:", err)
- }
-
- // Register resources with the server
- github.RegisterResources(ghServer, getClient, t)
- // Register the tools with the server
- toolsets.RegisterTools(ghServer)
- context.RegisterTools(ghServer)
-
- if dynamic {
- dynamic := github.InitDynamicToolset(ghServer, toolsets, t)
- dynamic.RegisterTools(ghServer)
- }
-
- stdioServer := server.NewStdioServer(ghServer)
-
- stdLogger := stdlog.New(cfg.logger.Writer(), "stdioserver", 0)
- stdioServer.SetErrorLogger(stdLogger)
-
- if cfg.exportTranslations {
- // Once server is initialized, all translations are loaded
- dumpTranslations()
- }
-
- // Start listening for messages
- errC := make(chan error, 1)
- go func() {
- in, out := io.Reader(os.Stdin), io.Writer(os.Stdout)
-
- if cfg.logCommands {
- loggedIO := iolog.NewIOLogger(in, out, cfg.logger)
- in, out = loggedIO, loggedIO
- }
-
- errC <- stdioServer.Listen(ctx, in, out)
- }()
-
- // Output github-mcp-server string
- _, _ = fmt.Fprintf(os.Stderr, "GitHub MCP Server running on stdio\n")
-
- // Wait for shutdown signal
- select {
- case <-ctx.Done():
- cfg.logger.Infof("shutting down server...")
- case err := <-errC:
- if err != nil {
- return fmt.Errorf("error running server: %w", err)
- }
- }
-
- return nil
-}
-
func main() {
if err := rootCmd.Execute(); err != nil {
- fmt.Println(err)
+ fmt.Fprintf(os.Stderr, "%v\n", err)
os.Exit(1)
}
}
diff --git a/cmd/mcpcurl/README.md b/cmd/mcpcurl/README.md
index 0104a1b35..493ce5b18 100644
--- a/cmd/mcpcurl/README.md
+++ b/cmd/mcpcurl/README.md
@@ -17,7 +17,7 @@ be executed against the configured MCP server.
## Usage
-```bash
+```console
mcpcurl --stdio-server-cmd="" [flags]
```
@@ -33,7 +33,7 @@ The `--stdio-server-cmd` flag is required for all commands and specifies the com
List available tools in Anthropic's MCP server:
-```bash
+```console
% ./mcpcurl --stdio-server-cmd "docker run -i --rm -e GITHUB_PERSONAL_ACCESS_TOKEN mcp/github" tools --help
Contains all dynamically generated tool commands from the schema
@@ -72,7 +72,7 @@ Use "mcpcurl tools [command] --help" for more information about a command.
Get help for a specific tool:
-```bash
+```console
% ./mcpcurl --stdio-server-cmd "docker run -i --rm -e GITHUB_PERSONAL_ACCESS_TOKEN mcp/github" tools get_issue --help
Get details of a specific issue in a GitHub repository
@@ -93,7 +93,7 @@ Global Flags:
Use one of the tools:
-```bash
+```console
% ./mcpcurl --stdio-server-cmd "docker run -i --rm -e GITHUB_PERSONAL_ACCESS_TOKEN mcp/github" tools get_issue --owner golang --repo go --issue_number 1
{
"active_lock_reason": null,
diff --git a/cmd/mcpcurl/main.go b/cmd/mcpcurl/main.go
index dfc639b99..bc192587a 100644
--- a/cmd/mcpcurl/main.go
+++ b/cmd/mcpcurl/main.go
@@ -77,7 +77,7 @@ type (
Arguments map[string]interface{} `json:"arguments"`
}
- // Define structure to match the response format
+ // Content matches the response format of a text content response
Content struct {
Type string `json:"type"`
Text string `json:"text"`
@@ -284,10 +284,10 @@ func addCommandFromTool(toolsCmd *cobra.Command, tool *Tool, prettyPrint bool) {
cmd.Flags().Bool(name, false, description)
case "array":
if prop.Items != nil {
- if prop.Items.Type == "string" {
+ switch prop.Items.Type {
+ case "string":
cmd.Flags().StringSlice(name, []string{}, description)
- } else if prop.Items.Type == "object" {
- // For complex objects in arrays, we'll use a JSON string that users can provide
+ case "object":
cmd.Flags().String(name+"-json", "", description+" (provide as JSON array)")
}
}
@@ -327,11 +327,12 @@ func buildArgumentsMap(cmd *cobra.Command, tool *Tool) (map[string]interface{},
}
case "array":
if prop.Items != nil {
- if prop.Items.Type == "string" {
+ switch prop.Items.Type {
+ case "string":
if values, _ := cmd.Flags().GetStringSlice(name); len(values) > 0 {
arguments[name] = values
}
- } else if prop.Items.Type == "object" {
+ case "object":
if jsonStr, _ := cmd.Flags().GetString(name + "-json"); jsonStr != "" {
var jsonArray []interface{}
if err := json.Unmarshal([]byte(jsonStr), &jsonArray); err != nil {
diff --git a/conformance/conformance_test.go b/conformance/conformance_test.go
deleted file mode 100644
index cd69e013a..000000000
--- a/conformance/conformance_test.go
+++ /dev/null
@@ -1,435 +0,0 @@
-//go:build conformance
-
-package conformance_test
-
-import (
- "bufio"
- "context"
- "encoding/json"
- "errors"
- "fmt"
- "io"
- "os"
- "reflect"
- "strings"
- "testing"
-
- "github.com/docker/docker/api/types/container"
- "github.com/docker/docker/api/types/network"
- "github.com/docker/docker/client"
- "github.com/docker/docker/pkg/stdcopy"
- "github.com/google/go-cmp/cmp"
- "github.com/google/go-cmp/cmp/cmpopts"
- "github.com/stretchr/testify/require"
-)
-
-type maintainer string
-
-const (
- anthropic maintainer = "anthropic"
- github maintainer = "github"
-)
-
-type testLogWriter struct {
- t *testing.T
-}
-
-func (w testLogWriter) Write(p []byte) (n int, err error) {
- w.t.Log(string(p))
- return len(p), nil
-}
-
-func start(t *testing.T, m maintainer) server {
- var image string
- if m == github {
- image = "github/github-mcp-server"
- } else {
- image = "mcp/github"
- }
-
- ctx := context.Background()
- dockerClient, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
- require.NoError(t, err)
-
- containerCfg := &container.Config{
- OpenStdin: true,
- AttachStdin: true,
- AttachStdout: true,
- AttachStderr: true,
- Env: []string{
- fmt.Sprintf("GITHUB_PERSONAL_ACCESS_TOKEN=%s", os.Getenv("GITHUB_PERSONAL_ACCESS_TOKEN")),
- },
- Image: image,
- }
-
- resp, err := dockerClient.ContainerCreate(
- ctx,
- containerCfg,
- &container.HostConfig{},
- &network.NetworkingConfig{},
- nil,
- "")
- require.NoError(t, err)
-
- t.Cleanup(func() {
- require.NoError(t, dockerClient.ContainerRemove(ctx, resp.ID, container.RemoveOptions{Force: true}))
- })
-
- hijackedResponse, err := dockerClient.ContainerAttach(ctx, resp.ID, container.AttachOptions{
- Stream: true,
- Stdin: true,
- Stdout: true,
- Stderr: true,
- })
- require.NoError(t, err)
- t.Cleanup(func() { hijackedResponse.Close() })
-
- require.NoError(t, dockerClient.ContainerStart(ctx, resp.ID, container.StartOptions{}))
-
- serverStart := make(chan serverStartResult)
- go func() {
- prOut, pwOut := io.Pipe()
- prErr, pwErr := io.Pipe()
-
- go func() {
- // Ignore error, we should be done?
- // TODO: maybe check for use of closed network connection specifically
- _, _ = stdcopy.StdCopy(pwOut, pwErr, hijackedResponse.Reader)
- pwOut.Close()
- pwErr.Close()
- }()
-
- bufferedStderr := bufio.NewReader(prErr)
- line, err := bufferedStderr.ReadString('\n')
- if err != nil {
- serverStart <- serverStartResult{err: err}
- }
-
- if strings.TrimSpace(line) != "GitHub MCP Server running on stdio" {
- serverStart <- serverStartResult{
- err: fmt.Errorf("unexpected server output: %s", line),
- }
- return
- }
-
- serverStart <- serverStartResult{
- server: server{
- m: m,
- log: testLogWriter{t},
- stdin: hijackedResponse.Conn,
- stdout: bufio.NewReader(prOut),
- },
- }
- }()
-
- t.Logf("waiting for %s server to start...", m)
- serveResult := <-serverStart
- require.NoError(t, serveResult.err, "expected the server to start successfully")
-
- return serveResult.server
-}
-
-func TestCapabilities(t *testing.T) {
- anthropicServer := start(t, anthropic)
- githubServer := start(t, github)
-
- req := initializeRequest{
- JSONRPC: "2.0",
- ID: 1,
- Method: "initialize",
- Params: initializeParams{
- ProtocolVersion: "2025-03-26",
- Capabilities: clientCapabilities{},
- ClientInfo: clientInfo{
- Name: "ConformanceTest",
- Version: "0.0.1",
- },
- },
- }
-
- require.NoError(t, anthropicServer.send(req))
-
- var anthropicInitializeResponse initializeResponse
- require.NoError(t, anthropicServer.receive(&anthropicInitializeResponse))
-
- require.NoError(t, githubServer.send(req))
-
- var ghInitializeResponse initializeResponse
- require.NoError(t, githubServer.receive(&ghInitializeResponse))
-
- // Any capabilities in the anthropic response should be present in the github response
- // (though the github response may have additional capabilities)
- if diff := diffNonNilFields(anthropicInitializeResponse.Result.Capabilities, ghInitializeResponse.Result.Capabilities, ""); diff != "" {
- t.Errorf("capabilities mismatch:\n%s", diff)
- }
-}
-
-func diffNonNilFields(a, b interface{}, path string) string {
- var sb strings.Builder
-
- va := reflect.ValueOf(a)
- vb := reflect.ValueOf(b)
-
- if !va.IsValid() {
- return ""
- }
-
- if va.Kind() == reflect.Ptr {
- if va.IsNil() {
- return ""
- }
- if !vb.IsValid() || vb.IsNil() {
- sb.WriteString(path + "\n")
- return sb.String()
- }
- va = va.Elem()
- vb = vb.Elem()
- }
-
- if va.Kind() != reflect.Struct || vb.Kind() != reflect.Struct {
- return ""
- }
-
- t := va.Type()
- for i := range va.NumField() {
- field := t.Field(i)
- if !field.IsExported() {
- continue
- }
-
- subPath := field.Name
- if path != "" {
- subPath = fmt.Sprintf("%s.%s", path, field.Name)
- }
-
- fieldA := va.Field(i)
- fieldB := vb.Field(i)
-
- switch fieldA.Kind() {
- case reflect.Ptr:
- if fieldA.IsNil() {
- continue // not required
- }
- if fieldB.IsNil() {
- sb.WriteString(subPath + "\n")
- continue
- }
- sb.WriteString(diffNonNilFields(fieldA.Interface(), fieldB.Interface(), subPath))
-
- case reflect.Struct:
- sb.WriteString(diffNonNilFields(fieldA.Interface(), fieldB.Interface(), subPath))
-
- default:
- zero := reflect.Zero(fieldA.Type())
- if !reflect.DeepEqual(fieldA.Interface(), zero.Interface()) {
- // fieldA is non-zero; now check that fieldB matches
- if !reflect.DeepEqual(fieldA.Interface(), fieldB.Interface()) {
- sb.WriteString(subPath + "\n")
- }
- }
- }
- }
-
- return sb.String()
-}
-
-func TestListTools(t *testing.T) {
- anthropicServer := start(t, anthropic)
- githubServer := start(t, github)
-
- req := listToolsRequest{
- JSONRPC: "2.0",
- ID: 1,
- Method: "tools/list",
- }
-
- require.NoError(t, anthropicServer.send(req))
-
- var anthropicListToolsResponse listToolsResponse
- require.NoError(t, anthropicServer.receive(&anthropicListToolsResponse))
-
- require.NoError(t, githubServer.send(req))
-
- var ghListToolsResponse listToolsResponse
- require.NoError(t, githubServer.receive(&ghListToolsResponse))
-
- require.NoError(t, isToolListSubset(anthropicListToolsResponse.Result, ghListToolsResponse.Result), "expected the github list tools response to be a subset of the anthropic list tools response")
-}
-
-func isToolListSubset(subset, superset listToolsResult) error {
- // Build a map from tool name to Tool from the superset
- supersetMap := make(map[string]tool)
- for _, tool := range superset.Tools {
- supersetMap[tool.Name] = tool
- }
-
- var err error
- for _, tool := range subset.Tools {
- sup, ok := supersetMap[tool.Name]
- if !ok {
- return fmt.Errorf("tool %q not found in superset", tool.Name)
- }
-
- // Intentionally ignore the description fields because there are lots of slight differences.
- // if tool.Description != sup.Description {
- // return fmt.Errorf("description mismatch for tool %q, got %q expected %q", tool.Name, tool.Description, sup.Description)
- // }
-
- // Ignore any description fields within the input schema properties for the same reason
- ignoreDescOpt := cmp.FilterPath(func(p cmp.Path) bool {
- // Look for a field named "Properties" somewhere in the path
- for _, ps := range p {
- if sf, ok := ps.(cmp.StructField); ok && sf.Name() == "Properties" {
- return true
- }
- }
- return false
- }, cmpopts.IgnoreMapEntries(func(k string, _ any) bool {
- return k == "description"
- }))
-
- if diff := cmp.Diff(tool.InputSchema, sup.InputSchema, ignoreDescOpt); diff != "" {
- err = errors.Join(err, fmt.Errorf("inputSchema mismatch for tool %q:\n%s", tool.Name, diff))
- }
- }
-
- return err
-}
-
-type serverStartResult struct {
- server server
- err error
-}
-
-type server struct {
- m maintainer
- log io.Writer
-
- stdin io.Writer
- stdout *bufio.Reader
-}
-
-func (s server) send(req request) error {
- b, err := req.marshal()
- if err != nil {
- return err
- }
-
- fmt.Fprintf(s.log, "sending %s: %s\n", s.m, string(b))
-
- n, err := s.stdin.Write(append(b, '\n'))
- if err != nil {
- return err
- }
-
- if n != len(b)+1 {
- return fmt.Errorf("wrote %d bytes, expected %d", n, len(b)+1)
- }
-
- return nil
-}
-
-func (s server) receive(res response) error {
- line, err := s.stdout.ReadBytes('\n')
- if err != nil {
- if err == io.EOF {
- return fmt.Errorf("EOF after reading %s", string(line))
- }
- return err
- }
-
- fmt.Fprintf(s.log, "received from %s: %s\n", s.m, string(line))
-
- return res.unmarshal(line)
-}
-
-type request interface {
- marshal() ([]byte, error)
-}
-
-type response interface {
- unmarshal([]byte) error
-}
-
-type jsonRPRCRequest[params any] struct {
- JSONRPC string `json:"jsonrpc"`
- ID int `json:"id"`
- Method string `json:"method"`
- Params params `json:"params"`
-}
-
-func (r jsonRPRCRequest[any]) marshal() ([]byte, error) {
- return json.Marshal(r)
-}
-
-type jsonRPRCResponse[result any] struct {
- JSONRPC string `json:"jsonrpc"`
- ID int `json:"id"`
- Method string `json:"method"`
- Result result `json:"result"`
-}
-
-func (r *jsonRPRCResponse[any]) unmarshal(b []byte) error {
- return json.Unmarshal(b, r)
-}
-
-type initializeRequest = jsonRPRCRequest[initializeParams]
-
-type initializeParams struct {
- ProtocolVersion string `json:"protocolVersion"`
- Capabilities clientCapabilities `json:"capabilities"`
- ClientInfo clientInfo `json:"clientInfo"`
-}
-
-type clientCapabilities struct{} // don't actually care about any of these right now
-
-type clientInfo struct {
- Name string `json:"name"`
- Version string `json:"version"`
-}
-
-type initializeResponse = jsonRPRCResponse[initializeResult]
-
-type initializeResult struct {
- ProtocolVersion string `json:"protocolVersion"`
- Capabilities serverCapabilities `json:"capabilities"`
- ServerInfo serverInfo `json:"serverInfo"`
-}
-
-type serverCapabilities struct {
- Logging *struct{} `json:"logging,omitempty"`
- Prompts *struct {
- ListChanged bool `json:"listChanged,omitempty"`
- } `json:"prompts,omitempty"`
- Resources *struct {
- Subscribe bool `json:"subscribe,omitempty"`
- ListChanged bool `json:"listChanged,omitempty"`
- } `json:"resources,omitempty"`
- Tools *struct {
- ListChanged bool `json:"listChanged,omitempty"`
- } `json:"tools,omitempty"`
-}
-
-type serverInfo struct {
- Name string `json:"name"`
- Version string `json:"version"`
-}
-
-type listToolsRequest = jsonRPRCRequest[struct{}]
-
-type listToolsResponse = jsonRPRCResponse[listToolsResult]
-
-type listToolsResult struct {
- Tools []tool `json:"tools"`
-}
-type tool struct {
- Name string `json:"name"`
- Description string `json:"description,omitempty"`
- InputSchema inputSchema `json:"inputSchema"`
-}
-
-type inputSchema struct {
- Type string `json:"type"`
- Properties map[string]any `json:"properties,omitempty"`
- Required []string `json:"required,omitempty"`
-}
diff --git a/docs/host-integration.md b/docs/host-integration.md
new file mode 100644
index 000000000..d9f6d9050
--- /dev/null
+++ b/docs/host-integration.md
@@ -0,0 +1,193 @@
+# GitHub Remote MCP Integration Guide for MCP Host Authors
+
+This guide outlines high-level considerations for MCP Host authors who want to allow installation of the Remote GitHub MCP server.
+
+The goal is to explain the architecture at a high-level, define key requirements, and provide guidance to get you started, while pointing to official documentation for deeper implementation details.
+
+---
+
+## Table of Contents
+
+- [Understanding MCP Architecture](#understanding-mcp-architecture)
+- [Connecting to the Remote GitHub MCP Server](#connecting-to-the-remote-github-mcp-server)
+ - [Authentication and Authorization](#authentication-and-authorization)
+ - [OAuth Support on GitHub](#oauth-support-on-github)
+ - [Create an OAuth-enabled App Using the GitHub UI](#create-an-oauth-enabled-app-using-the-github-ui)
+ - [Things to Consider](#things-to-consider)
+ - [Initiating the OAuth Flow from your Client Application](#initiating-the-oauth-flow-from-your-client-application)
+- [Handling Organization Access Restrictions](#handling-organization-access-restrictions)
+- [Essential Security Considerations](#essential-security-considerations)
+- [Additional Resources](#additional-resources)
+
+---
+
+## Understanding MCP Architecture
+
+The Model Context Protocol (MCP) enables seamless communication between your application and various external tools through an architecture defined by the [MCP Standard](https://modelcontextprotocol.io/).
+
+### High-level Architecture
+
+The diagram below illustrates how a single client application can connect to multiple MCP Servers, each providing access to a unique set of resources. Notice that some MCP Servers are running locally (side-by-side with the client application) while others are hosted remotely. GitHub's MCP offerings are available to run either locally or remotely.
+
+```mermaid
+flowchart LR
+ subgraph "Local Runtime Environment"
+ subgraph "Client Application (e.g., IDE)"
+ CLIENTAPP[Application Runtime]
+ CX["MCP Client (FileSystem)"]
+ CY["MCP Client (GitHub)"]
+ CZ["MCP Client (Other)"]
+ end
+
+ LOCALMCP[File System MCP Server]
+ end
+
+ subgraph "Internet"
+ GITHUBMCP[GitHub Remote MCP Server]
+ OTHERMCP[Other Remote MCP Server]
+ end
+
+ CLIENTAPP --> CX
+ CLIENTAPP --> CY
+ CLIENTAPP --> CZ
+
+ CX <-->|"stdio"| LOCALMCP
+ CY <-->|"OAuth 2.0 + HTTP/SSE"| GITHUBMCP
+ CZ <-->|"OAuth 2.0 + HTTP/SSE"| OTHERMCP
+```
+
+### Runtime Environment
+
+- **Application**: The user-facing application you are building. It instantiates one or more MCP clients and orchestrates tool calls.
+- **MCP Client**: A component within your client application that maintains a 1:1 connection with a single MCP server.
+- **MCP Server**: A service that provides access to a specific set of tools.
+ - **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).
+
+> [!NOTE]
+> GitHub offers both a Local MCP Server and a Remote MCP Server.
+
+---
+
+## Connecting to the Remote GitHub MCP Server
+
+### Authentication and Authorization
+
+GitHub MCP Servers require a valid access token in the `Authorization` header. This is true for both the Local GitHub MCP Server and the Remote GitHub MCP Server.
+
+For the Remote GitHub MCP Server, the recommended way to obtain a valid access token is to ensure your client application supports [OAuth 2.1](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-13). It should be noted, however, that you may also supply any valid access token. For example, you may supply a pre-generated Personal Access Token (PAT).
+
+
+> [!IMPORTANT]
+> 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.
+
+```mermaid
+sequenceDiagram
+ participant B as User-Agent (Browser)
+ participant C as Client
+ participant M as MCP Server (Resource Server)
+ participant A as Authorization Server
+
+ C->>M: MCP request without token
+ M->>C: HTTP 401 Unauthorized with WWW-Authenticate header
+ Note over C: Extract resource_metadata URL from WWW-Authenticate
+
+ C->>M: Request Protected Resource Metadata
+ M->>C: Return metadata
+
+ Note over C: Parse metadata and extract authorization server(s)
Client determines AS to use
+
+ C->>A: GET /.well-known/oauth-authorization-server
+ A->>C: Authorization server metadata response
+
+ alt Dynamic client registration
+ C->>A: POST /register
+ A->>C: Client Credentials
+ end
+
+ Note over C: Generate PKCE parameters
+ C->>B: Open browser with authorization URL + code_challenge
+ B->>A: Authorization request
+ Note over A: User authorizes
+ A->>B: Redirect to callback with authorization code
+ B->>C: Authorization code callback
+ C->>A: Token request + code_verifier
+ A->>C: Access token (+ refresh token)
+ C->>M: MCP request with access token
+ M-->>C: MCP response
+ Note over C,M: MCP communication continues with valid token
+```
+
+> [!NOTE]
+> Dynamic Client Registration is NOT supported by Remote GitHub MCP Server at this time.
+
+
+#### OAuth Support on GitHub
+
+GitHub offers two solutions for obtaining access tokens via OAuth: [**GitHub Apps**](https://docs.github.com/en/apps/using-github-apps/about-using-github-apps#about-github-apps) and [**OAuth Apps**](https://docs.github.com/en/apps/oauth-apps). These solutions are typically created, administered, and maintained by GitHub Organization administrators. Collaborate with a GitHub Organization administrator to configure either a **GitHub App** or an **OAuth App** to allow your client application to utilize GitHub OAuth support. Furthermore, be aware that it may be necessary for users of your client application to register your **GitHub App** or **OAuth App** within their own GitHub Organization in order to generate authorization tokens capable of accessing Organization's GitHub resources.
+
+> [!TIP]
+> Before proceeding, check whether your organization already supports one of these solutions. Administrators of your GitHub Organization can help you determine what **GitHub Apps** or **OAuth Apps** are already registered. If there's an existing **GitHub App** or **OAuth App** that fits your use case, consider reusing it for Remote MCP Authorization. That said, be sure to take heed of the following warning.
+
+> [!WARNING]
+> Both **GitHub Apps** and **OAuth Apps** require the client application to pass a "client secret" in order to initiate the OAuth flow. If your client application is designed to run in an uncontrolled environment (i.e. customer-provided hardware), end users will be able to discover your "client secret" and potentially exploit it for other purposes. In such cases, our recommendation is to register a new **GitHub App** (or **OAuth App**) exclusively dedicated to servicing OAuth requests from your client application.
+
+#### Create an OAuth-enabled App Using the GitHub UI
+
+Detailed instructions for creating a **GitHub App** can be found at ["Creating GitHub Apps"](https://docs.github.com/en/apps/creating-github-apps/about-creating-github-apps/about-creating-github-apps#building-a-github-app). (RECOMMENDED)
+Detailed instructions for creating an **OAuth App** can be found ["Creating an OAuth App"](https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/creating-an-oauth-app).
+
+For guidance on which type of app to choose, see ["Differences Between GitHub Apps and OAuth Apps"](https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/differences-between-github-apps-and-oauth-apps).
+
+#### Things to Consider:
+- Tokens provided by **GitHub Apps** are generally more secure because they:
+ - include an expiration
+ - include support for fine-grained permissions
+- **GitHub Apps** must be installed on a GitHub Organization before they can be used.
In general, installation must be approved by someone in the Organization with administrator permissions. For more details, see [this explanation](https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/differences-between-github-apps-and-oauth-apps#who-can-install-github-apps-and-authorize-oauth-apps).
By contrast, **OAuth Apps** don't require installation and, typically, can be used immediately.
+- Members of an Organization may use the GitHub UI to [request that a GitHub App be installed](https://docs.github.com/en/apps/using-github-apps/requesting-a-github-app-from-your-organization-owner) organization-wide.
+- While not strictly necessary, if you expect that a wide range of users will use your MCP Server, consider publishing its corresponding **GitHub App** or **OAuth App** on the [GitHub App Marketplace](https://github.com/marketplace?type=apps) to ensure that it's discoverable by your audience.
+
+
+#### Initiating the OAuth Flow from your Client Application
+
+For **GitHub Apps**, details on initiating the OAuth flow from a client application are described in detail [here](https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/generating-a-user-access-token-for-a-github-app#using-the-web-application-flow-to-generate-a-user-access-token).
+
+For **OAuth Apps**, details on initiating the OAuth flow from a client application are described in detail [here](https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/authorizing-oauth-apps#web-application-flow).
+
+> [!IMPORTANT]
+> For endpoint discovery, be sure to honor the [`WWW-Authenticate` information provided](https://modelcontextprotocol.io/specification/draft/basic/authorization#authorization-server-location) by the Remote GitHub MCP Server rather than relying on hard-coded endpoints like `https://github.com/login/oauth/authorize`.
+
+
+### Handling Organization Access Restrictions
+Organizations may block **GitHub Apps** and **OAuth Apps** until explicitly approved. Within your client application code, you can provide actionable next steps for a smooth user experience in the event that OAuth-related calls fail due to your **GitHub App** or **OAuth App** being unavailable (i.e. not registered within the user's organization).
+
+1. Detect the specific error.
+2. Notify the user clearly.
+3. Depending on their GitHub organization privileges:
+ - Org Members: Prompt them to request approval from a GitHub organization admin, within the organization where access has not been approved.
+ - Org Admins: Link them to the corresponding GitHub organization’s App approval settings at `https://github.com/organizations/[ORG_NAME]/settings/oauth_application_policy`
+
+
+## Essential Security Considerations
+- **Token Storage**: Use secure platform APIs (e.g. keytar for Node.js).
+- **Input Validation**: Sanitize all tool arguments.
+- **HTTPS Only**: Never send requests over plaintext HTTP. Always use HTTPS in production.
+- **PKCE:** We strongly recommend implementing [PKCE](https://datatracker.ietf.org/doc/html/rfc7636) for all OAuth flows to prevent code interception, to prepare for upcoming PKCE support.
+
+## Additional Resources
+- [MCP Official Spec](https://modelcontextprotocol.io/specification/draft)
+- [MCP SDKs](https://modelcontextprotocol.io/sdk/java/mcp-overview)
+- [GitHub Docs on Creating GitHub Apps](https://docs.github.com/en/apps/creating-github-apps)
+- [GitHub Docs on Using GitHub Apps](https://docs.github.com/en/apps/using-github-apps/about-using-github-apps)
+- [GitHub Docs on Creating OAuth Apps](https://docs.github.com/en/apps/oauth-apps)
+- GitHub Docs on Installing OAuth Apps into a [Personal Account](https://docs.github.com/en/apps/oauth-apps/using-oauth-apps/installing-an-oauth-app-in-your-personal-account) and [Organization](https://docs.github.com/en/apps/oauth-apps/using-oauth-apps/installing-an-oauth-app-in-your-organization)
+- [Managing OAuth Apps at the Organization Level](https://docs.github.com/en/organizations/managing-oauth-access-to-your-organizations-data)
+- [Managing Programmatic Access at the GitHub Organization Level](https://docs.github.com/en/organizations/managing-programmatic-access-to-your-organization)
+- [Building Copilot Extensions](https://docs.github.com/en/copilot/building-copilot-extensions)
+- [Managing App/Extension Visibility](https://docs.github.com/en/copilot/building-copilot-extensions/managing-the-availability-of-your-copilot-extension) (including GitHub Marketplace information)
+- [Example Implementation in VS Code Repository](https://github.com/microsoft/vscode/blob/main/src/vs/workbench/api/common/extHostMcp.ts#L313)
diff --git a/docs/remote-server.md b/docs/remote-server.md
new file mode 100644
index 000000000..888caef43
--- /dev/null
+++ b/docs/remote-server.md
@@ -0,0 +1,35 @@
+# Remote GitHub MCP Server 🚀
+
+[](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) [](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&quality=insiders)
+
+Easily connect to the GitHub MCP Server using the hosted version – no local setup or runtime required.
+
+**URL:** https://api.githubcopilot.com/mcp/
+
+## About
+
+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.
+
+## 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) |
+
+### Headers
+
+You can configure toolsets and readonly mode by providing HTTP headers in your server configuration.
+
+The headers are:
+- `X-MCP-Toolsets=,...`
+- `X-MCP-Readonly=true`
diff --git a/docs/testing.md b/docs/testing.md
new file mode 100644
index 000000000..dbdc3e080
--- /dev/null
+++ b/docs/testing.md
@@ -0,0 +1,34 @@
+# Testing
+
+This project uses a combination of unit tests and end-to-end (e2e) tests to ensure correctness and stability.
+
+## Unit Testing Patterns
+
+- Unit tests are located alongside implementation, with filenames ending in `_test.go`.
+- Currently the preference is to use internal tests i.e. test files do not have `_test` package suffix.
+- Tests use [testify](https://github.com/stretchr/testify) for assertions and require statements. Use `require` when continuing the test is not meaningful, for example it is almost never correct to continue after an error expectation.
+- Mocking is performed using [go-github-mock](https://github.com/migueleliasweb/go-github-mock) or `githubv4mock` for simulating GitHub rest and GQL API responses.
+- Each tool's schema is snapshotted and checked for changes using the `toolsnaps` utility (see below).
+- Tests are designed to be explicit and verbose to aid maintainability and clarity.
+- Handler unit tests should take the form of:
+ 1. Test tool snapshot
+ 1. Very important expectations against the schema (e.g. `ReadOnly` annotation)
+ 1. Behavioural tests in table-driven form
+
+## End-to-End (e2e) Tests
+
+- E2E tests are located in the [`e2e/`](../e2e/) directory. See the [e2e/README.md](../e2e/README.md) for full details on running and debugging these tests.
+
+## 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
+- 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
+committed.
+
+## Notes
+
+- Some tools that mutate global state (e.g., marking all notifications as read) are tested primarily with unit tests, not e2e, to avoid side effects.
+- For more on the limitations and philosophy of the e2e suite, see the [e2e/README.md](../e2e/README.md).
diff --git a/e2e/README.md b/e2e/README.md
new file mode 100644
index 000000000..62730431a
--- /dev/null
+++ b/e2e/README.md
@@ -0,0 +1,96 @@
+# End To End (e2e) Tests
+
+The purpose of the E2E tests is to have a simple (currently) test that gives maintainers some confidence in the black box behavior of our artifacts. It does this by:
+ * Building the `github-mcp-server` docker image
+ * Running the image
+ * Interacting with the server via stdio
+ * Issuing requests that interact with the live GitHub API
+
+## Running the Tests
+
+A service must be running that supports image building and container creation via the `docker` CLI.
+
+Since these tests require a token to interact with real resources on the GitHub API, it is gated behind the `e2e` build flag.
+
+```
+GITHUB_MCP_SERVER_E2E_TOKEN= go test -v --tags e2e ./e2e
+```
+
+The `GITHUB_MCP_SERVER_E2E_TOKEN` environment variable is mapped to `GITHUB_PERSONAL_ACCESS_TOKEN` internally, but separated to avoid accidental reuse of credentials.
+
+## Example
+
+The following diff adjusts the `get_me` tool to return `foobar` as the user login.
+
+```diff
+diff --git a/pkg/github/context_tools.go b/pkg/github/context_tools.go
+index 1c91d70..ac4ef2b 100644
+--- a/pkg/github/context_tools.go
++++ b/pkg/github/context_tools.go
+@@ -39,6 +39,8 @@ func GetMe(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mc
+ return mcp.NewToolResultError(fmt.Sprintf("failed to get user: %s", string(body))), nil
+ }
+
++ user.Login = sPtr("foobar")
++
+ r, err := json.Marshal(user)
+ if err != nil {
+ return nil, fmt.Errorf("failed to marshal user: %w", err)
+@@ -47,3 +49,7 @@ func GetMe(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mc
+ return mcp.NewToolResultText(string(r)), nil
+ }
+ }
++
++func sPtr(s string) *string {
++ return &s
++}
+```
+
+Running the tests:
+
+```
+➜ GITHUB_MCP_SERVER_E2E_TOKEN=$(gh auth token) go test -v --tags e2e ./e2e
+=== RUN TestE2E
+ e2e_test.go:92: Building Docker image for e2e tests...
+ e2e_test.go:36: Starting Stdio MCP client...
+=== RUN TestE2E/Initialize
+=== RUN TestE2E/CallTool_get_me
+ e2e_test.go:85:
+ Error Trace: /Users/williammartin/workspace/github-mcp-server/e2e/e2e_test.go:85
+ Error: Not equal:
+ expected: "foobar"
+ actual : "williammartin"
+
+ Diff:
+ --- Expected
+ +++ Actual
+ @@ -1 +1 @@
+ -foobar
+ +williammartin
+ Test: TestE2E/CallTool_get_me
+ Messages: expected login to match
+--- FAIL: TestE2E (1.05s)
+ --- PASS: TestE2E/Initialize (0.09s)
+ --- FAIL: TestE2E/CallTool_get_me (0.46s)
+FAIL
+FAIL github.com/github/github-mcp-server/e2e 1.433s
+FAIL
+```
+
+## Debugging the Tests
+
+It is possible to provide `GITHUB_MCP_SERVER_E2E_DEBUG=true` to run the e2e tests with an in-process version of the MCP server. This has slightly reduced coverage as it doesn't integrate with Docker, or make use of the cobra/viper configuration parsing. However, it allows for placing breakpoints in the MCP Server internals, supporting much better debugging flows than the fully black-box tests.
+
+One might argue that the lack of visibility into failures for the black box tests also indicates a product need, but this solves for the immediate pain point felt as a maintainer.
+
+## Limitations
+
+The current test suite is intentionally very limited in scope. This is because the maintenance costs on e2e tests tend to increase significantly over time. To read about some challenges with GitHub integration tests, see [go-github integration tests README](https://github.com/google/go-github/blob/5b75aa86dba5cf4af2923afa0938774f37fa0a67/test/README.md). We will expand this suite circumspectly!
+
+The tests are quite repetitive and verbose. This is intentional as we want to see them develop more before committing to abstractions.
+
+Currently, visibility into failures is not particularly good. We're hoping that we can pull apart the mcp-go client and have it hook into streams representing stdio without requiring an exec. This way we can get breakpoints in the debugger easily.
+
+### Global State Mutation Tests
+
+Some tools (such as those that mark all notifications as read) would change the global state for the tester, and are also not idempotent, so they offer little value for end to end tests and instead should rely on unit testing and manual verifications.
diff --git a/e2e/e2e_test.go b/e2e/e2e_test.go
new file mode 100644
index 000000000..e25dbda4f
--- /dev/null
+++ b/e2e/e2e_test.go
@@ -0,0 +1,1633 @@
+//go:build e2e
+
+package e2e_test
+
+import (
+ "context"
+ "encoding/base64"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "os"
+ "os/exec"
+ "slices"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+
+ "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"
+ mcpClient "github.com/mark3labs/mcp-go/client"
+ "github.com/mark3labs/mcp-go/mcp"
+ "github.com/stretchr/testify/require"
+)
+
+var (
+ // Shared variables and sync.Once instances to ensure one-time execution
+ getTokenOnce sync.Once
+ token string
+
+ getHostOnce sync.Once
+ host string
+
+ buildOnce sync.Once
+ buildError error
+)
+
+// getE2EToken ensures the environment variable is checked only once and returns the token
+func getE2EToken(t *testing.T) string {
+ getTokenOnce.Do(func() {
+ token = os.Getenv("GITHUB_MCP_SERVER_E2E_TOKEN")
+ if token == "" {
+ t.Fatalf("GITHUB_MCP_SERVER_E2E_TOKEN environment variable is not set")
+ }
+ })
+ return token
+}
+
+// getE2EHost ensures the environment variable is checked only once and returns the host
+func getE2EHost() string {
+ getHostOnce.Do(func() {
+ host = os.Getenv("GITHUB_MCP_SERVER_E2E_HOST")
+ })
+ return host
+}
+
+func getRESTClient(t *testing.T) *gogithub.Client {
+ // Get token and ensure Docker image is built
+ token := getE2EToken(t)
+
+ // Create a new GitHub client with the token
+ ghClient := gogithub.NewClient(nil).WithAuthToken(token)
+
+ if host := getE2EHost(); host != "" && host != "https://github.com" {
+ var err error
+ // Currently this works for GHEC because the API is exposed at the api subdomain and the path prefix
+ // but it would be preferable to extract the host parsing from the main server logic, and use it here.
+ ghClient, err = ghClient.WithEnterpriseURLs(host, host)
+ require.NoError(t, err, "expected to create GitHub client with host")
+ }
+
+ return ghClient
+}
+
+// ensureDockerImageBuilt makes sure the Docker image is built only once across all tests
+func ensureDockerImageBuilt(t *testing.T) {
+ buildOnce.Do(func() {
+ t.Log("Building Docker image for e2e tests...")
+ cmd := exec.Command("docker", "build", "-t", "github/e2e-github-mcp-server", ".")
+ cmd.Dir = ".." // Run this in the context of the root, where the Dockerfile is located.
+ output, err := cmd.CombinedOutput()
+ buildError = err
+ if err != nil {
+ t.Logf("Docker build output: %s", string(output))
+ }
+ })
+
+ // Check if the build was successful
+ require.NoError(t, buildError, "expected to build Docker image successfully")
+}
+
+// clientOpts holds configuration options for the MCP client setup
+type clientOpts struct {
+ // Toolsets to enable in the MCP server
+ enabledToolsets []string
+}
+
+// clientOption defines a function type for configuring ClientOpts
+type clientOption func(*clientOpts)
+
+// withToolsets returns an option that either sets the GITHUB_TOOLSETS envvar when executing in docker,
+// or sets the toolsets in the MCP server when running in-process.
+func withToolsets(toolsets []string) clientOption {
+ return func(opts *clientOpts) {
+ opts.enabledToolsets = toolsets
+ }
+}
+
+func setupMCPClient(t *testing.T, options ...clientOption) *mcpClient.Client {
+ // Get token and ensure Docker image is built
+ token := getE2EToken(t)
+
+ // Create and configure options
+ opts := &clientOpts{}
+
+ // Apply all options to configure the opts struct
+ for _, option := range options {
+ option(opts)
+ }
+
+ // By default, we run the tests including the Docker image, but with DEBUG
+ // enabled, we run the server in-process, allowing for easier debugging.
+ var client *mcpClient.Client
+ if os.Getenv("GITHUB_MCP_SERVER_E2E_DEBUG") == "" {
+ ensureDockerImageBuilt(t)
+
+ // Prepare Docker arguments
+ args := []string{
+ "docker",
+ "run",
+ "-i",
+ "--rm",
+ "-e",
+ "GITHUB_PERSONAL_ACCESS_TOKEN", // Personal access token is all required
+ }
+
+ host := getE2EHost()
+ if host != "" {
+ args = append(args, "-e", "GITHUB_HOST")
+ }
+
+ // Add toolsets environment variable to the Docker arguments
+ if len(opts.enabledToolsets) > 0 {
+ args = append(args, "-e", "GITHUB_TOOLSETS")
+ }
+
+ // Add the image name
+ args = append(args, "github/e2e-github-mcp-server")
+
+ // Construct the env vars for the MCP Client to execute docker with
+ dockerEnvVars := []string{
+ fmt.Sprintf("GITHUB_PERSONAL_ACCESS_TOKEN=%s", token),
+ fmt.Sprintf("GITHUB_TOOLSETS=%s", strings.Join(opts.enabledToolsets, ",")),
+ }
+
+ if host != "" {
+ dockerEnvVars = append(dockerEnvVars, fmt.Sprintf("GITHUB_HOST=%s", host))
+ }
+
+ // Create the client
+ t.Log("Starting Stdio MCP client...")
+ var err error
+ client, err = mcpClient.NewStdioMCPClient(args[0], dockerEnvVars, args[1:]...)
+ require.NoError(t, err, "expected to create client successfully")
+ } else {
+ // We need this because the fully compiled server has a default for the viper config, which is
+ // not in scope for using the MCP server directly. This probably indicates that we should refactor
+ // so that there is a shared setup mechanism, but let's wait till we feel more friction.
+ enabledToolsets := opts.enabledToolsets
+ if enabledToolsets == nil {
+ enabledToolsets = github.DefaultTools
+ }
+
+ ghServer, err := ghmcp.NewMCPServer(ghmcp.MCPServerConfig{
+ Token: token,
+ EnabledToolsets: enabledToolsets,
+ Host: getE2EHost(),
+ Translator: translations.NullTranslationHelper,
+ })
+ require.NoError(t, err, "expected to construct MCP server successfully")
+
+ t.Log("Starting In Process MCP client...")
+ client, err = mcpClient.NewInProcessClient(ghServer)
+ require.NoError(t, err, "expected to create in-process client successfully")
+ }
+
+ t.Cleanup(func() {
+ require.NoError(t, client.Close(), "expected to close client successfully")
+ })
+
+ // Initialize the client
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+
+ request := mcp.InitializeRequest{}
+ request.Params.ProtocolVersion = "2025-03-26"
+ request.Params.ClientInfo = mcp.Implementation{
+ Name: "e2e-test-client",
+ Version: "0.0.1",
+ }
+
+ result, err := client.Initialize(ctx, request)
+ require.NoError(t, err, "failed to initialize client")
+ require.Equal(t, "github-mcp-server", result.ServerInfo.Name, "unexpected server name")
+
+ return client
+}
+
+func TestGetMe(t *testing.T) {
+ t.Parallel()
+
+ mcpClient := setupMCPClient(t)
+ ctx := context.Background()
+
+ // When we call the "get_me" tool
+ request := mcp.CallToolRequest{}
+ request.Params.Name = "get_me"
+
+ response, err := mcpClient.CallTool(ctx, request)
+ require.NoError(t, err, "expected to call 'get_me' tool successfully")
+
+ require.False(t, response.IsError, "expected result not to be an error")
+ require.Len(t, response.Content, 1, "expected content to have one item")
+
+ textContent, ok := response.Content[0].(mcp.TextContent)
+ require.True(t, ok, "expected content to be of type TextContent")
+
+ var trimmedContent struct {
+ Login string `json:"login"`
+ }
+ err = json.Unmarshal([]byte(textContent.Text), &trimmedContent)
+ require.NoError(t, err, "expected to unmarshal text content successfully")
+
+ // Then the login in the response should match the login obtained via the same
+ // token using the GitHub API.
+ ghClient := getRESTClient(t)
+ user, _, err := ghClient.Users.Get(context.Background(), "")
+ require.NoError(t, err, "expected to get user successfully")
+ require.Equal(t, trimmedContent.Login, *user.Login, "expected login to match")
+
+}
+
+func TestToolsets(t *testing.T) {
+ t.Parallel()
+
+ mcpClient := setupMCPClient(
+ t,
+ withToolsets([]string{"repos", "issues"}),
+ )
+
+ ctx := context.Background()
+
+ request := mcp.ListToolsRequest{}
+ response, err := mcpClient.ListTools(ctx, request)
+ require.NoError(t, err, "expected to list tools successfully")
+
+ // We could enumerate the tools here, but we'll need to expose that information
+ // declaratively in the MCP server, so for the moment let's just check the existence
+ // of an issue and repo tool, and the non-existence of a pull_request tool.
+ var toolsContains = func(expectedName string) bool {
+ return slices.ContainsFunc(response.Tools, func(tool mcp.Tool) bool {
+ return tool.Name == expectedName
+ })
+ }
+
+ require.True(t, toolsContains("get_issue"), "expected to find 'get_issue' tool")
+ require.True(t, toolsContains("list_branches"), "expected to find 'list_branches' tool")
+ require.False(t, toolsContains("get_pull_request"), "expected not to find 'get_pull_request' tool")
+}
+
+func TestTags(t *testing.T) {
+ t.Parallel()
+
+ mcpClient := setupMCPClient(t)
+
+ ctx := context.Background()
+
+ // First, who am I
+ getMeRequest := mcp.CallToolRequest{}
+ getMeRequest.Params.Name = "get_me"
+
+ t.Log("Getting current user...")
+ resp, err := mcpClient.CallTool(ctx, getMeRequest)
+ require.NoError(t, err, "expected to call 'get_me' tool successfully")
+ require.False(t, resp.IsError, fmt.Sprintf("expected result not to be an error: %+v", resp))
+
+ require.False(t, resp.IsError, "expected result not to be an error")
+ require.Len(t, resp.Content, 1, "expected content to have one item")
+
+ textContent, ok := resp.Content[0].(mcp.TextContent)
+ require.True(t, ok, "expected content to be of type TextContent")
+
+ var trimmedGetMeText struct {
+ Login string `json:"login"`
+ }
+ err = json.Unmarshal([]byte(textContent.Text), &trimmedGetMeText)
+ require.NoError(t, err, "expected to unmarshal text content successfully")
+
+ currentOwner := trimmedGetMeText.Login
+
+ // Then create a repository with a README (via autoInit)
+ repoName := fmt.Sprintf("github-mcp-server-e2e-%s-%d", t.Name(), time.Now().UnixMilli())
+ createRepoRequest := mcp.CallToolRequest{}
+ createRepoRequest.Params.Name = "create_repository"
+ createRepoRequest.Params.Arguments = map[string]any{
+ "name": repoName,
+ "private": true,
+ "autoInit": true,
+ }
+
+ t.Logf("Creating repository %s/%s...", currentOwner, repoName)
+ _, err = mcpClient.CallTool(ctx, createRepoRequest)
+ require.NoError(t, err, "expected to call 'get_me' tool successfully")
+ require.False(t, resp.IsError, fmt.Sprintf("expected result not to be an error: %+v", resp))
+
+ // Cleanup the repository after the test
+ t.Cleanup(func() {
+ // MCP Server doesn't support deletions, but we can use the GitHub Client
+ ghClient := getRESTClient(t)
+ t.Logf("Deleting repository %s/%s...", currentOwner, repoName)
+ _, err := ghClient.Repositories.Delete(context.Background(), currentOwner, repoName)
+ require.NoError(t, err, "expected to delete repository successfully")
+ })
+
+ // Then create a tag
+ // MCP Server doesn't support tag creation, but we can use the GitHub Client
+ ghClient := getRESTClient(t)
+ t.Logf("Creating tag %s/%s:%s...", currentOwner, repoName, "v0.0.1")
+ ref, _, err := ghClient.Git.GetRef(context.Background(), currentOwner, repoName, "refs/heads/main")
+ require.NoError(t, err, "expected to get ref successfully")
+
+ tagObj, _, err := ghClient.Git.CreateTag(context.Background(), currentOwner, repoName, &gogithub.Tag{
+ Tag: gogithub.Ptr("v0.0.1"),
+ Message: gogithub.Ptr("v0.0.1"),
+ Object: &gogithub.GitObject{
+ SHA: ref.Object.SHA,
+ Type: gogithub.Ptr("commit"),
+ },
+ })
+ require.NoError(t, err, "expected to create tag object successfully")
+
+ _, _, err = ghClient.Git.CreateRef(context.Background(), currentOwner, repoName, &gogithub.Reference{
+ Ref: gogithub.Ptr("refs/tags/v0.0.1"),
+ Object: &gogithub.GitObject{
+ SHA: tagObj.SHA,
+ },
+ })
+ require.NoError(t, err, "expected to create tag ref successfully")
+
+ // List the tags
+ listTagsRequest := mcp.CallToolRequest{}
+ listTagsRequest.Params.Name = "list_tags"
+ listTagsRequest.Params.Arguments = map[string]any{
+ "owner": currentOwner,
+ "repo": repoName,
+ }
+
+ t.Logf("Listing tags for %s/%s...", currentOwner, repoName)
+ resp, err = mcpClient.CallTool(ctx, listTagsRequest)
+ require.NoError(t, err, "expected to call 'list_tags' tool successfully")
+ require.False(t, resp.IsError, fmt.Sprintf("expected result not to be an error: %+v", resp))
+
+ require.False(t, resp.IsError, "expected result not to be an error")
+ require.Len(t, resp.Content, 1, "expected content to have one item")
+
+ textContent, ok = resp.Content[0].(mcp.TextContent)
+ require.True(t, ok, "expected content to be of type TextContent")
+
+ var trimmedTags []struct {
+ Name string `json:"name"`
+ Commit struct {
+ SHA string `json:"sha"`
+ } `json:"commit"`
+ }
+ err = json.Unmarshal([]byte(textContent.Text), &trimmedTags)
+ require.NoError(t, err, "expected to unmarshal text content successfully")
+
+ require.Len(t, trimmedTags, 1, "expected to find one tag")
+ require.Equal(t, "v0.0.1", trimmedTags[0].Name, "expected tag name to match")
+ require.Equal(t, *ref.Object.SHA, trimmedTags[0].Commit.SHA, "expected tag SHA to match")
+
+ // And fetch an individual tag
+ getTagRequest := mcp.CallToolRequest{}
+ getTagRequest.Params.Name = "get_tag"
+ getTagRequest.Params.Arguments = map[string]any{
+ "owner": currentOwner,
+ "repo": repoName,
+ "tag": "v0.0.1",
+ }
+
+ t.Logf("Getting tag %s/%s:%s...", currentOwner, repoName, "v0.0.1")
+ resp, err = mcpClient.CallTool(ctx, getTagRequest)
+ require.NoError(t, err, "expected to call 'get_tag' tool successfully")
+ require.False(t, resp.IsError, "expected result not to be an error")
+
+ var trimmedTag []struct { // don't understand why this is an array
+ Name string `json:"name"`
+ Commit struct {
+ SHA string `json:"sha"`
+ } `json:"commit"`
+ }
+ err = json.Unmarshal([]byte(textContent.Text), &trimmedTag)
+ require.NoError(t, err, "expected to unmarshal text content successfully")
+ require.Len(t, trimmedTag, 1, "expected to find one tag")
+ require.Equal(t, "v0.0.1", trimmedTag[0].Name, "expected tag name to match")
+ require.Equal(t, *ref.Object.SHA, trimmedTag[0].Commit.SHA, "expected tag SHA to match")
+}
+
+func TestFileDeletion(t *testing.T) {
+ t.Parallel()
+
+ mcpClient := setupMCPClient(t)
+
+ ctx := context.Background()
+
+ // First, who am I
+ getMeRequest := mcp.CallToolRequest{}
+ getMeRequest.Params.Name = "get_me"
+
+ t.Log("Getting current user...")
+ resp, err := mcpClient.CallTool(ctx, getMeRequest)
+ require.NoError(t, err, "expected to call 'get_me' tool successfully")
+ require.False(t, resp.IsError, fmt.Sprintf("expected result not to be an error: %+v", resp))
+
+ require.False(t, resp.IsError, "expected result not to be an error")
+ require.Len(t, resp.Content, 1, "expected content to have one item")
+
+ textContent, ok := resp.Content[0].(mcp.TextContent)
+ require.True(t, ok, "expected content to be of type TextContent")
+
+ var trimmedGetMeText struct {
+ Login string `json:"login"`
+ }
+ err = json.Unmarshal([]byte(textContent.Text), &trimmedGetMeText)
+ require.NoError(t, err, "expected to unmarshal text content successfully")
+
+ currentOwner := trimmedGetMeText.Login
+
+ // Then create a repository with a README (via autoInit)
+ repoName := fmt.Sprintf("github-mcp-server-e2e-%s-%d", t.Name(), time.Now().UnixMilli())
+ createRepoRequest := mcp.CallToolRequest{}
+ createRepoRequest.Params.Name = "create_repository"
+ createRepoRequest.Params.Arguments = map[string]any{
+ "name": repoName,
+ "private": true,
+ "autoInit": true,
+ }
+ t.Logf("Creating repository %s/%s...", currentOwner, repoName)
+ _, err = mcpClient.CallTool(ctx, createRepoRequest)
+ require.NoError(t, err, "expected to call 'get_me' tool successfully")
+ require.False(t, resp.IsError, fmt.Sprintf("expected result not to be an error: %+v", resp))
+
+ // Cleanup the repository after the test
+ t.Cleanup(func() {
+ // MCP Server doesn't support deletions, but we can use the GitHub Client
+ ghClient := getRESTClient(t)
+ t.Logf("Deleting repository %s/%s...", currentOwner, repoName)
+ _, err := ghClient.Repositories.Delete(context.Background(), currentOwner, repoName)
+ require.NoError(t, err, "expected to delete repository successfully")
+ })
+
+ // Create a branch on which to create a new commit
+ createBranchRequest := mcp.CallToolRequest{}
+ createBranchRequest.Params.Name = "create_branch"
+ createBranchRequest.Params.Arguments = map[string]any{
+ "owner": currentOwner,
+ "repo": repoName,
+ "branch": "test-branch",
+ "from_branch": "main",
+ }
+
+ t.Logf("Creating branch in %s/%s...", currentOwner, repoName)
+ resp, err = mcpClient.CallTool(ctx, createBranchRequest)
+ require.NoError(t, err, "expected to call 'create_branch' tool successfully")
+ require.False(t, resp.IsError, fmt.Sprintf("expected result not to be an error: %+v", resp))
+
+ // Create a commit with a new file
+ commitRequest := mcp.CallToolRequest{}
+ commitRequest.Params.Name = "create_or_update_file"
+ commitRequest.Params.Arguments = map[string]any{
+ "owner": currentOwner,
+ "repo": repoName,
+ "path": "test-file.txt",
+ "content": fmt.Sprintf("Created by e2e test %s", t.Name()),
+ "message": "Add test file",
+ "branch": "test-branch",
+ }
+
+ t.Logf("Creating commit with new file in %s/%s...", currentOwner, repoName)
+ resp, err = mcpClient.CallTool(ctx, commitRequest)
+ require.NoError(t, err, "expected to call 'create_or_update_file' tool successfully")
+ require.False(t, resp.IsError, fmt.Sprintf("expected result not to be an error: %+v", resp))
+
+ // Check the file exists
+ getFileContentsRequest := mcp.CallToolRequest{}
+ getFileContentsRequest.Params.Name = "get_file_contents"
+ getFileContentsRequest.Params.Arguments = map[string]any{
+ "owner": currentOwner,
+ "repo": repoName,
+ "path": "test-file.txt",
+ "branch": "test-branch",
+ }
+
+ t.Logf("Getting file contents in %s/%s...", currentOwner, repoName)
+ resp, err = mcpClient.CallTool(ctx, getFileContentsRequest)
+ require.NoError(t, err, "expected to call 'get_file_contents' tool successfully")
+ require.False(t, resp.IsError, fmt.Sprintf("expected result not to be an error: %+v", resp))
+
+ textContent, ok = resp.Content[0].(mcp.TextContent)
+ require.True(t, ok, "expected content to be of type TextContent")
+
+ var trimmedGetFileText struct {
+ Content string `json:"content"`
+ }
+ err = json.Unmarshal([]byte(textContent.Text), &trimmedGetFileText)
+ require.NoError(t, err, "expected to unmarshal text content successfully")
+ b, err := base64.StdEncoding.DecodeString(trimmedGetFileText.Content)
+ require.NoError(t, err, "expected to decode base64 content successfully")
+ require.Equal(t, fmt.Sprintf("Created by e2e test %s", t.Name()), string(b), "expected file content to match")
+
+ // Delete the file
+ deleteFileRequest := mcp.CallToolRequest{}
+ deleteFileRequest.Params.Name = "delete_file"
+ deleteFileRequest.Params.Arguments = map[string]any{
+ "owner": currentOwner,
+ "repo": repoName,
+ "path": "test-file.txt",
+ "message": "Delete test file",
+ "branch": "test-branch",
+ }
+
+ t.Logf("Deleting file in %s/%s...", currentOwner, repoName)
+ resp, err = mcpClient.CallTool(ctx, deleteFileRequest)
+ require.NoError(t, err, "expected to call 'delete_file' tool successfully")
+ require.False(t, resp.IsError, fmt.Sprintf("expected result not to be an error: %+v", resp))
+
+ // See that there is a commit that removes the file
+ listCommitsRequest := mcp.CallToolRequest{}
+ listCommitsRequest.Params.Name = "list_commits"
+ listCommitsRequest.Params.Arguments = map[string]any{
+ "owner": currentOwner,
+ "repo": repoName,
+ "sha": "test-branch", // can be SHA or branch, which is an unfortunate API design
+ }
+
+ t.Logf("Listing commits in %s/%s...", currentOwner, repoName)
+ resp, err = mcpClient.CallTool(ctx, listCommitsRequest)
+ require.NoError(t, err, "expected to call 'list_commits' tool successfully")
+ require.False(t, resp.IsError, fmt.Sprintf("expected result not to be an error: %+v", resp))
+
+ textContent, ok = resp.Content[0].(mcp.TextContent)
+ require.True(t, ok, "expected content to be of type TextContent")
+
+ var trimmedListCommitsText []struct {
+ SHA string `json:"sha"`
+ Commit struct {
+ Message string `json:"message"`
+ }
+ Files []struct {
+ Filename string `json:"filename"`
+ Deletions int `json:"deletions"`
+ }
+ }
+ err = json.Unmarshal([]byte(textContent.Text), &trimmedListCommitsText)
+ require.NoError(t, err, "expected to unmarshal text content successfully")
+ require.GreaterOrEqual(t, len(trimmedListCommitsText), 1, "expected to find at least one commit")
+
+ deletionCommit := trimmedListCommitsText[0]
+ require.Equal(t, "Delete test file", deletionCommit.Commit.Message, "expected commit message to match")
+
+ // Now get the commit so we can look at the file changes because list_commits doesn't include them
+ getCommitRequest := mcp.CallToolRequest{}
+ getCommitRequest.Params.Name = "get_commit"
+ getCommitRequest.Params.Arguments = map[string]any{
+ "owner": currentOwner,
+ "repo": repoName,
+ "sha": deletionCommit.SHA,
+ }
+
+ t.Logf("Getting commit %s/%s:%s...", currentOwner, repoName, deletionCommit.SHA)
+ resp, err = mcpClient.CallTool(ctx, getCommitRequest)
+ require.NoError(t, err, "expected to call 'get_commit' tool successfully")
+ require.False(t, resp.IsError, fmt.Sprintf("expected result not to be an error: %+v", resp))
+
+ textContent, ok = resp.Content[0].(mcp.TextContent)
+ require.True(t, ok, "expected content to be of type TextContent")
+
+ var trimmedGetCommitText struct {
+ Files []struct {
+ Filename string `json:"filename"`
+ Deletions int `json:"deletions"`
+ }
+ }
+ err = json.Unmarshal([]byte(textContent.Text), &trimmedGetCommitText)
+ require.NoError(t, err, "expected to unmarshal text content successfully")
+ require.Len(t, trimmedGetCommitText.Files, 1, "expected to find one file change")
+ require.Equal(t, "test-file.txt", trimmedGetCommitText.Files[0].Filename, "expected filename to match")
+ require.Equal(t, 1, trimmedGetCommitText.Files[0].Deletions, "expected one deletion")
+}
+
+func TestDirectoryDeletion(t *testing.T) {
+ t.Parallel()
+
+ mcpClient := setupMCPClient(t)
+
+ ctx := context.Background()
+
+ // First, who am I
+ getMeRequest := mcp.CallToolRequest{}
+ getMeRequest.Params.Name = "get_me"
+
+ t.Log("Getting current user...")
+ resp, err := mcpClient.CallTool(ctx, getMeRequest)
+ require.NoError(t, err, "expected to call 'get_me' tool successfully")
+ require.False(t, resp.IsError, fmt.Sprintf("expected result not to be an error: %+v", resp))
+
+ require.False(t, resp.IsError, "expected result not to be an error")
+ require.Len(t, resp.Content, 1, "expected content to have one item")
+
+ textContent, ok := resp.Content[0].(mcp.TextContent)
+ require.True(t, ok, "expected content to be of type TextContent")
+
+ var trimmedGetMeText struct {
+ Login string `json:"login"`
+ }
+ err = json.Unmarshal([]byte(textContent.Text), &trimmedGetMeText)
+ require.NoError(t, err, "expected to unmarshal text content successfully")
+
+ currentOwner := trimmedGetMeText.Login
+
+ // Then create a repository with a README (via autoInit)
+ repoName := fmt.Sprintf("github-mcp-server-e2e-%s-%d", t.Name(), time.Now().UnixMilli())
+ createRepoRequest := mcp.CallToolRequest{}
+ createRepoRequest.Params.Name = "create_repository"
+ createRepoRequest.Params.Arguments = map[string]any{
+ "name": repoName,
+ "private": true,
+ "autoInit": true,
+ }
+ t.Logf("Creating repository %s/%s...", currentOwner, repoName)
+ _, err = mcpClient.CallTool(ctx, createRepoRequest)
+ require.NoError(t, err, "expected to call 'get_me' tool successfully")
+ require.False(t, resp.IsError, fmt.Sprintf("expected result not to be an error: %+v", resp))
+
+ // Cleanup the repository after the test
+ t.Cleanup(func() {
+ // MCP Server doesn't support deletions, but we can use the GitHub Client
+ ghClient := getRESTClient(t)
+ t.Logf("Deleting repository %s/%s...", currentOwner, repoName)
+ _, err := ghClient.Repositories.Delete(context.Background(), currentOwner, repoName)
+ require.NoError(t, err, "expected to delete repository successfully")
+ })
+
+ // Create a branch on which to create a new commit
+ createBranchRequest := mcp.CallToolRequest{}
+ createBranchRequest.Params.Name = "create_branch"
+ createBranchRequest.Params.Arguments = map[string]any{
+ "owner": currentOwner,
+ "repo": repoName,
+ "branch": "test-branch",
+ "from_branch": "main",
+ }
+
+ t.Logf("Creating branch in %s/%s...", currentOwner, repoName)
+ resp, err = mcpClient.CallTool(ctx, createBranchRequest)
+ require.NoError(t, err, "expected to call 'create_branch' tool successfully")
+ require.False(t, resp.IsError, fmt.Sprintf("expected result not to be an error: %+v", resp))
+
+ // Create a commit with a new file
+ commitRequest := mcp.CallToolRequest{}
+ commitRequest.Params.Name = "create_or_update_file"
+ commitRequest.Params.Arguments = map[string]any{
+ "owner": currentOwner,
+ "repo": repoName,
+ "path": "test-dir/test-file.txt",
+ "content": fmt.Sprintf("Created by e2e test %s", t.Name()),
+ "message": "Add test file",
+ "branch": "test-branch",
+ }
+
+ t.Logf("Creating commit with new file in %s/%s...", currentOwner, repoName)
+ resp, err = mcpClient.CallTool(ctx, commitRequest)
+ require.NoError(t, err, "expected to call 'create_or_update_file' tool successfully")
+ require.False(t, resp.IsError, fmt.Sprintf("expected result not to be an error: %+v", resp))
+
+ textContent, ok = resp.Content[0].(mcp.TextContent)
+ require.True(t, ok, "expected content to be of type TextContent")
+
+ // Check the file exists
+ getFileContentsRequest := mcp.CallToolRequest{}
+ getFileContentsRequest.Params.Name = "get_file_contents"
+ getFileContentsRequest.Params.Arguments = map[string]any{
+ "owner": currentOwner,
+ "repo": repoName,
+ "path": "test-dir/test-file.txt",
+ "branch": "test-branch",
+ }
+
+ t.Logf("Getting file contents in %s/%s...", currentOwner, repoName)
+ resp, err = mcpClient.CallTool(ctx, getFileContentsRequest)
+ require.NoError(t, err, "expected to call 'get_file_contents' tool successfully")
+ require.False(t, resp.IsError, fmt.Sprintf("expected result not to be an error: %+v", resp))
+
+ textContent, ok = resp.Content[0].(mcp.TextContent)
+ require.True(t, ok, "expected content to be of type TextContent")
+
+ var trimmedGetFileText struct {
+ Content string `json:"content"`
+ }
+ err = json.Unmarshal([]byte(textContent.Text), &trimmedGetFileText)
+ require.NoError(t, err, "expected to unmarshal text content successfully")
+ b, err := base64.StdEncoding.DecodeString(trimmedGetFileText.Content)
+ require.NoError(t, err, "expected to decode base64 content successfully")
+ require.Equal(t, fmt.Sprintf("Created by e2e test %s", t.Name()), string(b), "expected file content to match")
+
+ // Delete the directory containing the file
+ deleteFileRequest := mcp.CallToolRequest{}
+ deleteFileRequest.Params.Name = "delete_file"
+ deleteFileRequest.Params.Arguments = map[string]any{
+ "owner": currentOwner,
+ "repo": repoName,
+ "path": "test-dir",
+ "message": "Delete test directory",
+ "branch": "test-branch",
+ }
+
+ t.Logf("Deleting directory in %s/%s...", currentOwner, repoName)
+ resp, err = mcpClient.CallTool(ctx, deleteFileRequest)
+ require.NoError(t, err, "expected to call 'delete_file' tool successfully")
+ require.False(t, resp.IsError, fmt.Sprintf("expected result not to be an error: %+v", resp))
+
+ // See that there is a commit that removes the directory
+ listCommitsRequest := mcp.CallToolRequest{}
+ listCommitsRequest.Params.Name = "list_commits"
+ listCommitsRequest.Params.Arguments = map[string]any{
+ "owner": currentOwner,
+ "repo": repoName,
+ "sha": "test-branch", // can be SHA or branch, which is an unfortunate API design
+ }
+
+ t.Logf("Listing commits in %s/%s...", currentOwner, repoName)
+ resp, err = mcpClient.CallTool(ctx, listCommitsRequest)
+ require.NoError(t, err, "expected to call 'list_commits' tool successfully")
+ require.False(t, resp.IsError, fmt.Sprintf("expected result not to be an error: %+v", resp))
+
+ textContent, ok = resp.Content[0].(mcp.TextContent)
+ require.True(t, ok, "expected content to be of type TextContent")
+
+ var trimmedListCommitsText []struct {
+ SHA string `json:"sha"`
+ Commit struct {
+ Message string `json:"message"`
+ }
+ Files []struct {
+ Filename string `json:"filename"`
+ Deletions int `json:"deletions"`
+ } `json:"files"`
+ }
+ err = json.Unmarshal([]byte(textContent.Text), &trimmedListCommitsText)
+ require.NoError(t, err, "expected to unmarshal text content successfully")
+ require.GreaterOrEqual(t, len(trimmedListCommitsText), 1, "expected to find at least one commit")
+
+ deletionCommit := trimmedListCommitsText[0]
+ require.Equal(t, "Delete test directory", deletionCommit.Commit.Message, "expected commit message to match")
+
+ // Now get the commit so we can look at the file changes because list_commits doesn't include them
+ getCommitRequest := mcp.CallToolRequest{}
+ getCommitRequest.Params.Name = "get_commit"
+ getCommitRequest.Params.Arguments = map[string]any{
+ "owner": currentOwner,
+ "repo": repoName,
+ "sha": deletionCommit.SHA,
+ }
+
+ t.Logf("Getting commit %s/%s:%s...", currentOwner, repoName, deletionCommit.SHA)
+ resp, err = mcpClient.CallTool(ctx, getCommitRequest)
+ require.NoError(t, err, "expected to call 'get_commit' tool successfully")
+ require.False(t, resp.IsError, fmt.Sprintf("expected result not to be an error: %+v", resp))
+
+ textContent, ok = resp.Content[0].(mcp.TextContent)
+ require.True(t, ok, "expected content to be of type TextContent")
+
+ var trimmedGetCommitText struct {
+ Files []struct {
+ Filename string `json:"filename"`
+ Deletions int `json:"deletions"`
+ }
+ }
+ err = json.Unmarshal([]byte(textContent.Text), &trimmedGetCommitText)
+ require.NoError(t, err, "expected to unmarshal text content successfully")
+ require.Len(t, trimmedGetCommitText.Files, 1, "expected to find one file change")
+ require.Equal(t, "test-dir/test-file.txt", trimmedGetCommitText.Files[0].Filename, "expected filename to match")
+ require.Equal(t, 1, trimmedGetCommitText.Files[0].Deletions, "expected one deletion")
+}
+
+func TestRequestCopilotReview(t *testing.T) {
+ t.Parallel()
+
+ if getE2EHost() != "" && getE2EHost() != "https://github.com" {
+ t.Skip("Skipping test because the host does not support copilot reviews")
+ }
+
+ mcpClient := setupMCPClient(t)
+ ctx := context.Background()
+
+ // First, who am I
+ getMeRequest := mcp.CallToolRequest{}
+ getMeRequest.Params.Name = "get_me"
+
+ t.Log("Getting current user...")
+ resp, err := mcpClient.CallTool(ctx, getMeRequest)
+ require.NoError(t, err, "expected to call 'get_me' tool successfully")
+ require.False(t, resp.IsError, fmt.Sprintf("expected result not to be an error: %+v", resp))
+
+ require.False(t, resp.IsError, "expected result not to be an error")
+ require.Len(t, resp.Content, 1, "expected content to have one item")
+
+ textContent, ok := resp.Content[0].(mcp.TextContent)
+ require.True(t, ok, "expected content to be of type TextContent")
+
+ var trimmedGetMeText struct {
+ Login string `json:"login"`
+ }
+ err = json.Unmarshal([]byte(textContent.Text), &trimmedGetMeText)
+ require.NoError(t, err, "expected to unmarshal text content successfully")
+
+ currentOwner := trimmedGetMeText.Login
+
+ // Then create a repository with a README (via autoInit)
+ repoName := fmt.Sprintf("github-mcp-server-e2e-%s-%d", t.Name(), time.Now().UnixMilli())
+ createRepoRequest := mcp.CallToolRequest{}
+ createRepoRequest.Params.Name = "create_repository"
+ createRepoRequest.Params.Arguments = map[string]any{
+ "name": repoName,
+ "private": true,
+ "autoInit": true,
+ }
+
+ t.Logf("Creating repository %s/%s...", currentOwner, repoName)
+ _, err = mcpClient.CallTool(ctx, createRepoRequest)
+ require.NoError(t, err, "expected to call 'create_repository' tool successfully")
+ require.False(t, resp.IsError, fmt.Sprintf("expected result not to be an error: %+v", resp))
+
+ // Cleanup the repository after the test
+ t.Cleanup(func() {
+ // MCP Server doesn't support deletions, but we can use the GitHub Client
+ ghClient := gogithub.NewClient(nil).WithAuthToken(getE2EToken(t))
+ t.Logf("Deleting repository %s/%s...", currentOwner, repoName)
+ _, err := ghClient.Repositories.Delete(context.Background(), currentOwner, repoName)
+ require.NoError(t, err, "expected to delete repository successfully")
+ })
+
+ // Create a branch on which to create a new commit
+ createBranchRequest := mcp.CallToolRequest{}
+ createBranchRequest.Params.Name = "create_branch"
+ createBranchRequest.Params.Arguments = map[string]any{
+ "owner": currentOwner,
+ "repo": repoName,
+ "branch": "test-branch",
+ "from_branch": "main",
+ }
+
+ t.Logf("Creating branch in %s/%s...", currentOwner, repoName)
+ resp, err = mcpClient.CallTool(ctx, createBranchRequest)
+ require.NoError(t, err, "expected to call 'create_branch' tool successfully")
+ require.False(t, resp.IsError, fmt.Sprintf("expected result not to be an error: %+v", resp))
+
+ // Create a commit with a new file
+ commitRequest := mcp.CallToolRequest{}
+ commitRequest.Params.Name = "create_or_update_file"
+ commitRequest.Params.Arguments = map[string]any{
+ "owner": currentOwner,
+ "repo": repoName,
+ "path": "test-file.txt",
+ "content": fmt.Sprintf("Created by e2e test %s", t.Name()),
+ "message": "Add test file",
+ "branch": "test-branch",
+ }
+
+ t.Logf("Creating commit with new file in %s/%s...", currentOwner, repoName)
+ resp, err = mcpClient.CallTool(ctx, commitRequest)
+ require.NoError(t, err, "expected to call 'create_or_update_file' tool successfully")
+ require.False(t, resp.IsError, fmt.Sprintf("expected result not to be an error: %+v", resp))
+
+ textContent, ok = resp.Content[0].(mcp.TextContent)
+ require.True(t, ok, "expected content to be of type TextContent")
+
+ var trimmedCommitText struct {
+ SHA string `json:"sha"`
+ }
+ err = json.Unmarshal([]byte(textContent.Text), &trimmedCommitText)
+ require.NoError(t, err, "expected to unmarshal text content successfully")
+ commitId := trimmedCommitText.SHA
+
+ // Create a pull request
+ prRequest := mcp.CallToolRequest{}
+ prRequest.Params.Name = "create_pull_request"
+ prRequest.Params.Arguments = map[string]any{
+ "owner": currentOwner,
+ "repo": repoName,
+ "title": "Test PR",
+ "body": "This is a test PR",
+ "head": "test-branch",
+ "base": "main",
+ "commitId": commitId,
+ }
+
+ t.Logf("Creating pull request in %s/%s...", currentOwner, repoName)
+ resp, err = mcpClient.CallTool(ctx, prRequest)
+ require.NoError(t, err, "expected to call 'create_pull_request' tool successfully")
+ require.False(t, resp.IsError, fmt.Sprintf("expected result not to be an error: %+v", resp))
+
+ // Request a copilot review
+ requestCopilotReviewRequest := mcp.CallToolRequest{}
+ requestCopilotReviewRequest.Params.Name = "request_copilot_review"
+ requestCopilotReviewRequest.Params.Arguments = map[string]any{
+ "owner": currentOwner,
+ "repo": repoName,
+ "pullNumber": 1,
+ }
+
+ t.Logf("Requesting Copilot review for pull request in %s/%s...", currentOwner, repoName)
+ resp, err = mcpClient.CallTool(ctx, requestCopilotReviewRequest)
+ require.NoError(t, err, "expected to call 'request_copilot_review' tool successfully")
+ require.False(t, resp.IsError, fmt.Sprintf("expected result not to be an error: %+v", resp))
+
+ textContent, ok = resp.Content[0].(mcp.TextContent)
+ require.True(t, ok, "expected content to be of type TextContent")
+ require.Equal(t, "", textContent.Text, "expected content to be empty")
+
+ // Finally, get requested reviews and see copilot is in there
+ // MCP Server doesn't support requesting reviews yet, but we can use the GitHub Client
+ ghClient := gogithub.NewClient(nil).WithAuthToken(getE2EToken(t))
+ t.Logf("Getting reviews for pull request in %s/%s...", currentOwner, repoName)
+ reviewRequests, _, err := ghClient.PullRequests.ListReviewers(context.Background(), currentOwner, repoName, 1, nil)
+ require.NoError(t, err, "expected to get review requests successfully")
+
+ // Check that there is one review request from copilot
+ require.Len(t, reviewRequests.Users, 1, "expected to find one review request")
+ require.Equal(t, "Copilot", *reviewRequests.Users[0].Login, "expected review request to be for Copilot")
+ require.Equal(t, "Bot", *reviewRequests.Users[0].Type, "expected review request to be for Bot")
+}
+
+func TestAssignCopilotToIssue(t *testing.T) {
+ t.Parallel()
+
+ if getE2EHost() != "" && getE2EHost() != "https://github.com" {
+ t.Skip("Skipping test because the host does not support copilot being assigned to issues")
+ }
+
+ mcpClient := setupMCPClient(t)
+ ctx := context.Background()
+
+ // First, who am I
+ getMeRequest := mcp.CallToolRequest{}
+ getMeRequest.Params.Name = "get_me"
+
+ t.Log("Getting current user...")
+ resp, err := mcpClient.CallTool(ctx, getMeRequest)
+ require.NoError(t, err, "expected to call 'get_me' tool successfully")
+ require.False(t, resp.IsError, fmt.Sprintf("expected result not to be an error: %+v", resp))
+
+ require.False(t, resp.IsError, "expected result not to be an error")
+ require.Len(t, resp.Content, 1, "expected content to have one item")
+
+ textContent, ok := resp.Content[0].(mcp.TextContent)
+ require.True(t, ok, "expected content to be of type TextContent")
+
+ var trimmedGetMeText struct {
+ Login string `json:"login"`
+ }
+ err = json.Unmarshal([]byte(textContent.Text), &trimmedGetMeText)
+ require.NoError(t, err, "expected to unmarshal text content successfully")
+
+ currentOwner := trimmedGetMeText.Login
+
+ // Then create a repository with a README (via autoInit)
+ repoName := fmt.Sprintf("github-mcp-server-e2e-%s-%d", t.Name(), time.Now().UnixMilli())
+ createRepoRequest := mcp.CallToolRequest{}
+ createRepoRequest.Params.Name = "create_repository"
+ createRepoRequest.Params.Arguments = map[string]any{
+ "name": repoName,
+ "private": true,
+ "autoInit": true,
+ }
+
+ t.Logf("Creating repository %s/%s...", currentOwner, repoName)
+ _, err = mcpClient.CallTool(ctx, createRepoRequest)
+ require.NoError(t, err, "expected to call 'create_repository' tool successfully")
+ require.False(t, resp.IsError, fmt.Sprintf("expected result not to be an error: %+v", resp))
+
+ // Cleanup the repository after the test
+ t.Cleanup(func() {
+ // MCP Server doesn't support deletions, but we can use the GitHub Client
+ ghClient := getRESTClient(t)
+ t.Logf("Deleting repository %s/%s...", currentOwner, repoName)
+ _, err := ghClient.Repositories.Delete(context.Background(), currentOwner, repoName)
+ require.NoError(t, err, "expected to delete repository successfully")
+ })
+
+ // Create an issue
+ createIssueRequest := mcp.CallToolRequest{}
+ createIssueRequest.Params.Name = "create_issue"
+ createIssueRequest.Params.Arguments = map[string]any{
+ "owner": currentOwner,
+ "repo": repoName,
+ "title": "Test issue to assign copilot to",
+ }
+
+ t.Logf("Creating issue in %s/%s...", currentOwner, repoName)
+ resp, err = mcpClient.CallTool(ctx, createIssueRequest)
+ require.NoError(t, err, "expected to call 'create_issue' tool successfully")
+ require.False(t, resp.IsError, fmt.Sprintf("expected result not to be an error: %+v", resp))
+
+ // Assign copilot to the issue
+ assignCopilotRequest := mcp.CallToolRequest{}
+ assignCopilotRequest.Params.Name = "assign_copilot_to_issue"
+ assignCopilotRequest.Params.Arguments = map[string]any{
+ "owner": currentOwner,
+ "repo": repoName,
+ "issueNumber": 1,
+ }
+
+ t.Logf("Assigning copilot to issue in %s/%s...", currentOwner, repoName)
+ resp, err = mcpClient.CallTool(ctx, assignCopilotRequest)
+ require.NoError(t, err, "expected to call 'assign_copilot_to_issue' tool successfully")
+
+ textContent, ok = resp.Content[0].(mcp.TextContent)
+ require.True(t, ok, "expected content to be of type TextContent")
+
+ possibleExpectedFailure := "copilot isn't available as an assignee for this issue. Please inform the user to visit https://docs.github.com/en/copilot/using-github-copilot/using-copilot-coding-agent-to-work-on-tasks/about-assigning-tasks-to-copilot for more information."
+ if resp.IsError && textContent.Text == possibleExpectedFailure {
+ t.Skip("skipping because copilot wasn't available as an assignee on this issue, it's likely that the owner doesn't have copilot enabled in their settings")
+ }
+
+ require.False(t, resp.IsError, fmt.Sprintf("expected result not to be an error: %+v", resp))
+
+ require.Equal(t, "successfully assigned copilot to issue", textContent.Text)
+
+ // Check that copilot is assigned to the issue
+ // MCP Server doesn't support getting assignees yet
+ ghClient := getRESTClient(t)
+ assignees, response, err := ghClient.Issues.Get(context.Background(), currentOwner, repoName, 1)
+ require.NoError(t, err, "expected to get issue successfully")
+ require.Equal(t, http.StatusOK, response.StatusCode, "expected to get issue successfully")
+ require.Len(t, assignees.Assignees, 1, "expected to find one assignee")
+ require.Equal(t, "Copilot", *assignees.Assignees[0].Login, "expected copilot to be assigned to the issue")
+}
+
+func TestPullRequestAtomicCreateAndSubmit(t *testing.T) {
+ t.Parallel()
+
+ mcpClient := setupMCPClient(t)
+
+ ctx := context.Background()
+
+ // First, who am I
+ getMeRequest := mcp.CallToolRequest{}
+ getMeRequest.Params.Name = "get_me"
+
+ t.Log("Getting current user...")
+ resp, err := mcpClient.CallTool(ctx, getMeRequest)
+ require.NoError(t, err, "expected to call 'get_me' tool successfully")
+ require.False(t, resp.IsError, fmt.Sprintf("expected result not to be an error: %+v", resp))
+
+ require.False(t, resp.IsError, "expected result not to be an error")
+ require.Len(t, resp.Content, 1, "expected content to have one item")
+
+ textContent, ok := resp.Content[0].(mcp.TextContent)
+ require.True(t, ok, "expected content to be of type TextContent")
+
+ var trimmedGetMeText struct {
+ Login string `json:"login"`
+ }
+ err = json.Unmarshal([]byte(textContent.Text), &trimmedGetMeText)
+ require.NoError(t, err, "expected to unmarshal text content successfully")
+
+ currentOwner := trimmedGetMeText.Login
+
+ // Then create a repository with a README (via autoInit)
+ repoName := fmt.Sprintf("github-mcp-server-e2e-%s-%d", t.Name(), time.Now().UnixMilli())
+ createRepoRequest := mcp.CallToolRequest{}
+ createRepoRequest.Params.Name = "create_repository"
+ createRepoRequest.Params.Arguments = map[string]any{
+ "name": repoName,
+ "private": true,
+ "autoInit": true,
+ }
+
+ t.Logf("Creating repository %s/%s...", currentOwner, repoName)
+ _, err = mcpClient.CallTool(ctx, createRepoRequest)
+ require.NoError(t, err, "expected to call 'get_me' tool successfully")
+ require.False(t, resp.IsError, fmt.Sprintf("expected result not to be an error: %+v", resp))
+
+ // Cleanup the repository after the test
+ t.Cleanup(func() {
+ // MCP Server doesn't support deletions, but we can use the GitHub Client
+ ghClient := getRESTClient(t)
+ t.Logf("Deleting repository %s/%s...", currentOwner, repoName)
+ _, err := ghClient.Repositories.Delete(context.Background(), currentOwner, repoName)
+ require.NoError(t, err, "expected to delete repository successfully")
+ })
+
+ // Create a branch on which to create a new commit
+ createBranchRequest := mcp.CallToolRequest{}
+ createBranchRequest.Params.Name = "create_branch"
+ createBranchRequest.Params.Arguments = map[string]any{
+ "owner": currentOwner,
+ "repo": repoName,
+ "branch": "test-branch",
+ "from_branch": "main",
+ }
+
+ t.Logf("Creating branch in %s/%s...", currentOwner, repoName)
+ resp, err = mcpClient.CallTool(ctx, createBranchRequest)
+ require.NoError(t, err, "expected to call 'create_branch' tool successfully")
+ require.False(t, resp.IsError, fmt.Sprintf("expected result not to be an error: %+v", resp))
+
+ // Create a commit with a new file
+ commitRequest := mcp.CallToolRequest{}
+ commitRequest.Params.Name = "create_or_update_file"
+ commitRequest.Params.Arguments = map[string]any{
+ "owner": currentOwner,
+ "repo": repoName,
+ "path": "test-file.txt",
+ "content": fmt.Sprintf("Created by e2e test %s", t.Name()),
+ "message": "Add test file",
+ "branch": "test-branch",
+ }
+
+ t.Logf("Creating commit with new file in %s/%s...", currentOwner, repoName)
+ resp, err = mcpClient.CallTool(ctx, commitRequest)
+ require.NoError(t, err, "expected to call 'create_or_update_file' tool successfully")
+ require.False(t, resp.IsError, fmt.Sprintf("expected result not to be an error: %+v", resp))
+
+ textContent, ok = resp.Content[0].(mcp.TextContent)
+ require.True(t, ok, "expected content to be of type TextContent")
+
+ var trimmedCommitText struct {
+ Commit struct {
+ SHA string `json:"sha"`
+ } `json:"commit"`
+ }
+ err = json.Unmarshal([]byte(textContent.Text), &trimmedCommitText)
+ require.NoError(t, err, "expected to unmarshal text content successfully")
+ commitID := trimmedCommitText.Commit.SHA
+
+ // Create a pull request
+ prRequest := mcp.CallToolRequest{}
+ prRequest.Params.Name = "create_pull_request"
+ prRequest.Params.Arguments = map[string]any{
+ "owner": currentOwner,
+ "repo": repoName,
+ "title": "Test PR",
+ "body": "This is a test PR",
+ "head": "test-branch",
+ "base": "main",
+ }
+
+ t.Logf("Creating pull request in %s/%s...", currentOwner, repoName)
+ resp, err = mcpClient.CallTool(ctx, prRequest)
+ require.NoError(t, err, "expected to call 'create_pull_request' tool successfully")
+ require.False(t, resp.IsError, fmt.Sprintf("expected result not to be an error: %+v", resp))
+
+ // Create and submit a review
+ createAndSubmitReviewRequest := mcp.CallToolRequest{}
+ createAndSubmitReviewRequest.Params.Name = "create_and_submit_pull_request_review"
+ createAndSubmitReviewRequest.Params.Arguments = map[string]any{
+ "owner": currentOwner,
+ "repo": repoName,
+ "pullNumber": 1,
+ "event": "COMMENT", // the only event we can use as the creator of the PR
+ "body": "Looks good if you like bad code I guess!",
+ "commitID": commitID,
+ }
+
+ t.Logf("Creating and submitting review for pull request in %s/%s...", currentOwner, repoName)
+ resp, err = mcpClient.CallTool(ctx, createAndSubmitReviewRequest)
+ require.NoError(t, err, "expected to call 'create_and_submit_pull_request_review' tool successfully")
+ require.False(t, resp.IsError, fmt.Sprintf("expected result not to be an error: %+v", resp))
+
+ // Finally, get the list of reviews and see that our review has been submitted
+ getPullRequestsReview := mcp.CallToolRequest{}
+ getPullRequestsReview.Params.Name = "get_pull_request_reviews"
+ getPullRequestsReview.Params.Arguments = map[string]any{
+ "owner": currentOwner,
+ "repo": repoName,
+ "pullNumber": 1,
+ }
+
+ t.Logf("Getting reviews for pull request in %s/%s...", currentOwner, repoName)
+ resp, err = mcpClient.CallTool(ctx, getPullRequestsReview)
+ require.NoError(t, err, "expected to call 'get_pull_request_reviews' tool successfully")
+ require.False(t, resp.IsError, fmt.Sprintf("expected result not to be an error: %+v", resp))
+
+ textContent, ok = resp.Content[0].(mcp.TextContent)
+ require.True(t, ok, "expected content to be of type TextContent")
+
+ var reviews []struct {
+ State string `json:"state"`
+ }
+ err = json.Unmarshal([]byte(textContent.Text), &reviews)
+ require.NoError(t, err, "expected to unmarshal text content successfully")
+
+ // Check that there is one review
+ require.Len(t, reviews, 1, "expected to find one review")
+ require.Equal(t, "COMMENTED", reviews[0].State, "expected review state to be COMMENTED")
+}
+
+func TestPullRequestReviewCommentSubmit(t *testing.T) {
+ t.Parallel()
+
+ mcpClient := setupMCPClient(t)
+
+ ctx := context.Background()
+
+ // First, who am I
+ getMeRequest := mcp.CallToolRequest{}
+ getMeRequest.Params.Name = "get_me"
+
+ t.Log("Getting current user...")
+ resp, err := mcpClient.CallTool(ctx, getMeRequest)
+ require.NoError(t, err, "expected to call 'get_me' tool successfully")
+ require.False(t, resp.IsError, fmt.Sprintf("expected result not to be an error: %+v", resp))
+
+ require.False(t, resp.IsError, "expected result not to be an error")
+ require.Len(t, resp.Content, 1, "expected content to have one item")
+
+ textContent, ok := resp.Content[0].(mcp.TextContent)
+ require.True(t, ok, "expected content to be of type TextContent")
+
+ var trimmedGetMeText struct {
+ Login string `json:"login"`
+ }
+ err = json.Unmarshal([]byte(textContent.Text), &trimmedGetMeText)
+ require.NoError(t, err, "expected to unmarshal text content successfully")
+
+ currentOwner := trimmedGetMeText.Login
+
+ // Then create a repository with a README (via autoInit)
+ repoName := fmt.Sprintf("github-mcp-server-e2e-%s-%d", t.Name(), time.Now().UnixMilli())
+ createRepoRequest := mcp.CallToolRequest{}
+ createRepoRequest.Params.Name = "create_repository"
+ createRepoRequest.Params.Arguments = map[string]any{
+ "name": repoName,
+ "private": true,
+ "autoInit": true,
+ }
+
+ t.Logf("Creating repository %s/%s...", currentOwner, repoName)
+ _, err = mcpClient.CallTool(ctx, createRepoRequest)
+ require.NoError(t, err, "expected to call 'create_repository' tool successfully")
+ require.False(t, resp.IsError, fmt.Sprintf("expected result not to be an error: %+v", resp))
+
+ // Cleanup the repository after the test
+ t.Cleanup(func() {
+ // MCP Server doesn't support deletions, but we can use the GitHub Client
+ ghClient := getRESTClient(t)
+ t.Logf("Deleting repository %s/%s...", currentOwner, repoName)
+ _, err := ghClient.Repositories.Delete(context.Background(), currentOwner, repoName)
+ require.NoError(t, err, "expected to delete repository successfully")
+ })
+
+ // Create a branch on which to create a new commit
+ createBranchRequest := mcp.CallToolRequest{}
+ createBranchRequest.Params.Name = "create_branch"
+ createBranchRequest.Params.Arguments = map[string]any{
+ "owner": currentOwner,
+ "repo": repoName,
+ "branch": "test-branch",
+ "from_branch": "main",
+ }
+
+ t.Logf("Creating branch in %s/%s...", currentOwner, repoName)
+ resp, err = mcpClient.CallTool(ctx, createBranchRequest)
+ require.NoError(t, err, "expected to call 'create_branch' tool successfully")
+ require.False(t, resp.IsError, fmt.Sprintf("expected result not to be an error: %+v", resp))
+
+ // Create a commit with a new file
+ commitRequest := mcp.CallToolRequest{}
+ commitRequest.Params.Name = "create_or_update_file"
+ commitRequest.Params.Arguments = map[string]any{
+ "owner": currentOwner,
+ "repo": repoName,
+ "path": "test-file.txt",
+ "content": fmt.Sprintf("Created by e2e test %s\nwith multiple lines", t.Name()),
+ "message": "Add test file",
+ "branch": "test-branch",
+ }
+
+ t.Logf("Creating commit with new file in %s/%s...", currentOwner, repoName)
+ resp, err = mcpClient.CallTool(ctx, commitRequest)
+ require.NoError(t, err, "expected to call 'create_or_update_file' tool successfully")
+ require.False(t, resp.IsError, fmt.Sprintf("expected result not to be an error: %+v", resp))
+
+ textContent, ok = resp.Content[0].(mcp.TextContent)
+ require.True(t, ok, "expected content to be of type TextContent")
+
+ var trimmedCommitText struct {
+ Commit struct {
+ SHA string `json:"sha"`
+ } `json:"commit"`
+ }
+ err = json.Unmarshal([]byte(textContent.Text), &trimmedCommitText)
+ require.NoError(t, err, "expected to unmarshal text content successfully")
+ commitId := trimmedCommitText.Commit.SHA
+
+ // Create a pull request
+ prRequest := mcp.CallToolRequest{}
+ prRequest.Params.Name = "create_pull_request"
+ prRequest.Params.Arguments = map[string]any{
+ "owner": currentOwner,
+ "repo": repoName,
+ "title": "Test PR",
+ "body": "This is a test PR",
+ "head": "test-branch",
+ "base": "main",
+ }
+
+ t.Logf("Creating pull request in %s/%s...", currentOwner, repoName)
+ resp, err = mcpClient.CallTool(ctx, prRequest)
+ require.NoError(t, err, "expected to call 'create_pull_request' tool successfully")
+ require.False(t, resp.IsError, fmt.Sprintf("expected result not to be an error: %+v", resp))
+
+ // Create a review for the pull request, but we can't approve it
+ // because the current owner also owns the PR.
+ createPendingPullRequestReviewRequest := mcp.CallToolRequest{}
+ createPendingPullRequestReviewRequest.Params.Name = "create_pending_pull_request_review"
+ createPendingPullRequestReviewRequest.Params.Arguments = map[string]any{
+ "owner": currentOwner,
+ "repo": repoName,
+ "pullNumber": 1,
+ }
+
+ t.Logf("Creating pending review for pull request in %s/%s...", currentOwner, repoName)
+ resp, err = mcpClient.CallTool(ctx, createPendingPullRequestReviewRequest)
+ require.NoError(t, err, "expected to call 'create_pending_pull_request_review' tool successfully")
+ require.False(t, resp.IsError, fmt.Sprintf("expected result not to be an error: %+v", resp))
+
+ textContent, ok = resp.Content[0].(mcp.TextContent)
+ require.True(t, ok, "expected content to be of type TextContent")
+ require.Equal(t, "pending pull request created", textContent.Text)
+
+ // Add a file review comment
+ addFileReviewCommentRequest := mcp.CallToolRequest{}
+ addFileReviewCommentRequest.Params.Name = "add_pull_request_review_comment_to_pending_review"
+ addFileReviewCommentRequest.Params.Arguments = map[string]any{
+ "owner": currentOwner,
+ "repo": repoName,
+ "pullNumber": 1,
+ "path": "test-file.txt",
+ "subjectType": "FILE",
+ "body": "File review comment",
+ }
+
+ 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.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.Arguments = map[string]any{
+ "owner": currentOwner,
+ "repo": repoName,
+ "pullNumber": 1,
+ "path": "test-file.txt",
+ "subjectType": "LINE",
+ "body": "Single line review comment",
+ "line": 1,
+ "side": "RIGHT",
+ "commitId": commitId,
+ }
+
+ 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.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.Arguments = map[string]any{
+ "owner": currentOwner,
+ "repo": repoName,
+ "pullNumber": 1,
+ "path": "test-file.txt",
+ "subjectType": "LINE",
+ "body": "Multiline review comment",
+ "startLine": 1,
+ "line": 2,
+ "startSide": "RIGHT",
+ "side": "RIGHT",
+ "commitId": commitId,
+ }
+
+ 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.False(t, resp.IsError, fmt.Sprintf("expected result not to be an error: %+v", resp))
+
+ // Submit the review
+ submitReviewRequest := mcp.CallToolRequest{}
+ submitReviewRequest.Params.Name = "submit_pending_pull_request_review"
+ submitReviewRequest.Params.Arguments = map[string]any{
+ "owner": currentOwner,
+ "repo": repoName,
+ "pullNumber": 1,
+ "event": "COMMENT", // the only event we can use as the creator of the PR
+ "body": "Looks good if you like bad code I guess!",
+ }
+
+ t.Logf("Submitting review for pull request in %s/%s...", currentOwner, repoName)
+ resp, err = mcpClient.CallTool(ctx, submitReviewRequest)
+ require.NoError(t, err, "expected to call 'submit_pending_pull_request_review' tool successfully")
+ require.False(t, resp.IsError, fmt.Sprintf("expected result not to be an error: %+v", resp))
+
+ // Finally, get the review and see that it has been created
+ getPullRequestsReview := mcp.CallToolRequest{}
+ getPullRequestsReview.Params.Name = "get_pull_request_reviews"
+ getPullRequestsReview.Params.Arguments = map[string]any{
+ "owner": currentOwner,
+ "repo": repoName,
+ "pullNumber": 1,
+ }
+
+ t.Logf("Getting reviews for pull request in %s/%s...", currentOwner, repoName)
+ resp, err = mcpClient.CallTool(ctx, getPullRequestsReview)
+ require.NoError(t, err, "expected to call 'get_pull_request_reviews' tool successfully")
+ require.False(t, resp.IsError, fmt.Sprintf("expected result not to be an error: %+v", resp))
+
+ textContent, ok = resp.Content[0].(mcp.TextContent)
+ require.True(t, ok, "expected content to be of type TextContent")
+
+ var reviews []struct {
+ ID int `json:"id"`
+ State string `json:"state"`
+ }
+ err = json.Unmarshal([]byte(textContent.Text), &reviews)
+ require.NoError(t, err, "expected to unmarshal text content successfully")
+
+ // Check that there is one review
+ require.Len(t, reviews, 1, "expected to find one review")
+ require.Equal(t, "COMMENTED", reviews[0].State, "expected review state to be COMMENTED")
+
+ // Check that there are three review comments
+ // MCP Server doesn't support this, but we can use the GitHub Client
+ ghClient := getRESTClient(t)
+ comments, _, err := ghClient.PullRequests.ListReviewComments(context.Background(), currentOwner, repoName, 1, int64(reviews[0].ID), nil)
+ require.NoError(t, err, "expected to list review comments successfully")
+ require.Equal(t, 3, len(comments), "expected to find three review comments")
+}
+
+func TestPullRequestReviewDeletion(t *testing.T) {
+ t.Parallel()
+
+ mcpClient := setupMCPClient(t)
+
+ ctx := context.Background()
+
+ // First, who am I
+ getMeRequest := mcp.CallToolRequest{}
+ getMeRequest.Params.Name = "get_me"
+
+ t.Log("Getting current user...")
+ resp, err := mcpClient.CallTool(ctx, getMeRequest)
+ require.NoError(t, err, "expected to call 'get_me' tool successfully")
+ require.False(t, resp.IsError, fmt.Sprintf("expected result not to be an error: %+v", resp))
+
+ require.False(t, resp.IsError, "expected result not to be an error")
+ require.Len(t, resp.Content, 1, "expected content to have one item")
+
+ textContent, ok := resp.Content[0].(mcp.TextContent)
+ require.True(t, ok, "expected content to be of type TextContent")
+
+ var trimmedGetMeText struct {
+ Login string `json:"login"`
+ }
+ err = json.Unmarshal([]byte(textContent.Text), &trimmedGetMeText)
+ require.NoError(t, err, "expected to unmarshal text content successfully")
+
+ currentOwner := trimmedGetMeText.Login
+
+ // Then create a repository with a README (via autoInit)
+ repoName := fmt.Sprintf("github-mcp-server-e2e-%s-%d", t.Name(), time.Now().UnixMilli())
+ createRepoRequest := mcp.CallToolRequest{}
+ createRepoRequest.Params.Name = "create_repository"
+ createRepoRequest.Params.Arguments = map[string]any{
+ "name": repoName,
+ "private": true,
+ "autoInit": true,
+ }
+
+ t.Logf("Creating repository %s/%s...", currentOwner, repoName)
+ _, err = mcpClient.CallTool(ctx, createRepoRequest)
+ require.NoError(t, err, "expected to call 'get_me' tool successfully")
+ require.False(t, resp.IsError, fmt.Sprintf("expected result not to be an error: %+v", resp))
+
+ // Cleanup the repository after the test
+ t.Cleanup(func() {
+ // MCP Server doesn't support deletions, but we can use the GitHub Client
+ ghClient := getRESTClient(t)
+ t.Logf("Deleting repository %s/%s...", currentOwner, repoName)
+ _, err := ghClient.Repositories.Delete(context.Background(), currentOwner, repoName)
+ require.NoError(t, err, "expected to delete repository successfully")
+ })
+
+ // Create a branch on which to create a new commit
+ createBranchRequest := mcp.CallToolRequest{}
+ createBranchRequest.Params.Name = "create_branch"
+ createBranchRequest.Params.Arguments = map[string]any{
+ "owner": currentOwner,
+ "repo": repoName,
+ "branch": "test-branch",
+ "from_branch": "main",
+ }
+
+ t.Logf("Creating branch in %s/%s...", currentOwner, repoName)
+ resp, err = mcpClient.CallTool(ctx, createBranchRequest)
+ require.NoError(t, err, "expected to call 'create_branch' tool successfully")
+ require.False(t, resp.IsError, fmt.Sprintf("expected result not to be an error: %+v", resp))
+
+ // Create a commit with a new file
+ commitRequest := mcp.CallToolRequest{}
+ commitRequest.Params.Name = "create_or_update_file"
+ commitRequest.Params.Arguments = map[string]any{
+ "owner": currentOwner,
+ "repo": repoName,
+ "path": "test-file.txt",
+ "content": fmt.Sprintf("Created by e2e test %s", t.Name()),
+ "message": "Add test file",
+ "branch": "test-branch",
+ }
+
+ t.Logf("Creating commit with new file in %s/%s...", currentOwner, repoName)
+ resp, err = mcpClient.CallTool(ctx, commitRequest)
+ require.NoError(t, err, "expected to call 'create_or_update_file' tool successfully")
+ require.False(t, resp.IsError, fmt.Sprintf("expected result not to be an error: %+v", resp))
+
+ // Create a pull request
+ prRequest := mcp.CallToolRequest{}
+ prRequest.Params.Name = "create_pull_request"
+ prRequest.Params.Arguments = map[string]any{
+ "owner": currentOwner,
+ "repo": repoName,
+ "title": "Test PR",
+ "body": "This is a test PR",
+ "head": "test-branch",
+ "base": "main",
+ }
+
+ t.Logf("Creating pull request in %s/%s...", currentOwner, repoName)
+ resp, err = mcpClient.CallTool(ctx, prRequest)
+ require.NoError(t, err, "expected to call 'create_pull_request' tool successfully")
+ require.False(t, resp.IsError, fmt.Sprintf("expected result not to be an error: %+v", resp))
+
+ // Create a review for the pull request, but we can't approve it
+ // because the current owner also owns the PR.
+ createPendingPullRequestReviewRequest := mcp.CallToolRequest{}
+ createPendingPullRequestReviewRequest.Params.Name = "create_pending_pull_request_review"
+ createPendingPullRequestReviewRequest.Params.Arguments = map[string]any{
+ "owner": currentOwner,
+ "repo": repoName,
+ "pullNumber": 1,
+ }
+
+ t.Logf("Creating pending review for pull request in %s/%s...", currentOwner, repoName)
+ resp, err = mcpClient.CallTool(ctx, createPendingPullRequestReviewRequest)
+ require.NoError(t, err, "expected to call 'create_pending_pull_request_review' tool successfully")
+ require.False(t, resp.IsError, fmt.Sprintf("expected result not to be an error: %+v", resp))
+
+ textContent, ok = resp.Content[0].(mcp.TextContent)
+ require.True(t, ok, "expected content to be of type TextContent")
+ require.Equal(t, "pending pull request created", textContent.Text)
+
+ // See that there is a pending review
+ getPullRequestsReview := mcp.CallToolRequest{}
+ getPullRequestsReview.Params.Name = "get_pull_request_reviews"
+ getPullRequestsReview.Params.Arguments = map[string]any{
+ "owner": currentOwner,
+ "repo": repoName,
+ "pullNumber": 1,
+ }
+
+ t.Logf("Getting reviews for pull request in %s/%s...", currentOwner, repoName)
+ resp, err = mcpClient.CallTool(ctx, getPullRequestsReview)
+ require.NoError(t, err, "expected to call 'get_pull_request_reviews' tool successfully")
+ require.False(t, resp.IsError, fmt.Sprintf("expected result not to be an error: %+v", resp))
+
+ textContent, ok = resp.Content[0].(mcp.TextContent)
+ require.True(t, ok, "expected content to be of type TextContent")
+
+ var reviews []struct {
+ State string `json:"state"`
+ }
+ err = json.Unmarshal([]byte(textContent.Text), &reviews)
+ require.NoError(t, err, "expected to unmarshal text content successfully")
+
+ // Check that there is one review
+ require.Len(t, reviews, 1, "expected to find one review")
+ require.Equal(t, "PENDING", reviews[0].State, "expected review state to be PENDING")
+
+ // Delete the review
+ deleteReviewRequest := mcp.CallToolRequest{}
+ deleteReviewRequest.Params.Name = "delete_pending_pull_request_review"
+ deleteReviewRequest.Params.Arguments = map[string]any{
+ "owner": currentOwner,
+ "repo": repoName,
+ "pullNumber": 1,
+ }
+
+ t.Logf("Deleting review for pull request in %s/%s...", currentOwner, repoName)
+ resp, err = mcpClient.CallTool(ctx, deleteReviewRequest)
+ require.NoError(t, err, "expected to call 'delete_pending_pull_request_review' tool successfully")
+ require.False(t, resp.IsError, fmt.Sprintf("expected result not to be an error: %+v", resp))
+
+ // See that there are no reviews
+ t.Logf("Getting reviews for pull request in %s/%s...", currentOwner, repoName)
+ resp, err = mcpClient.CallTool(ctx, getPullRequestsReview)
+ require.NoError(t, err, "expected to call 'get_pull_request_reviews' tool successfully")
+ require.False(t, resp.IsError, fmt.Sprintf("expected result not to be an error: %+v", resp))
+
+ textContent, ok = resp.Content[0].(mcp.TextContent)
+ require.True(t, ok, "expected content to be of type TextContent")
+
+ var noReviews []struct{}
+ err = json.Unmarshal([]byte(textContent.Text), &noReviews)
+ require.NoError(t, err, "expected to unmarshal text content successfully")
+ require.Len(t, noReviews, 0, "expected to find no reviews")
+}
diff --git a/go.mod b/go.mod
index 7c09fba91..ab2302ed5 100644
--- a/go.mod
+++ b/go.mod
@@ -3,11 +3,10 @@ module github.com/github/github-mcp-server
go 1.23.7
require (
- github.com/docker/docker v28.0.4+incompatible
- github.com/google/go-cmp v0.7.0
- github.com/google/go-github/v69 v69.2.0
- github.com/mark3labs/mcp-go v0.20.1
- github.com/migueleliasweb/go-github-mock v1.1.0
+ github.com/google/go-github/v72 v72.0.0
+ github.com/josephburnett/jd v1.9.2
+ github.com/mark3labs/mcp-go v0.31.0
+ github.com/migueleliasweb/go-github-mock v1.3.0
github.com/sirupsen/logrus v1.9.3
github.com/spf13/cobra v1.9.1
github.com/spf13/viper v1.20.1
@@ -15,52 +14,41 @@ require (
)
require (
- github.com/Microsoft/go-winio v0.6.2 // indirect
- github.com/containerd/log v0.1.0 // indirect
+ github.com/go-openapi/jsonpointer v0.19.5 // indirect
+ github.com/go-openapi/swag v0.21.1 // indirect
+ github.com/josharian/intern v1.0.0 // indirect
+ github.com/mailru/easyjson v0.7.7 // indirect
+ github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82 // indirect
+ golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 // indirect
+ gopkg.in/yaml.v2 v2.4.0 // indirect
+)
+
+require (
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
- github.com/distribution/reference v0.6.0 // indirect
- github.com/docker/go-connections v0.5.0 // indirect
- github.com/docker/go-units v0.5.0 // indirect
- github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/fsnotify/fsnotify v1.8.0 // indirect
- github.com/go-logr/logr v1.4.2 // indirect
- github.com/go-logr/stdr v1.2.2 // indirect
- github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
- github.com/gogo/protobuf v1.3.2 // indirect
- github.com/google/go-github/v64 v64.0.0 // indirect
+ github.com/go-viper/mapstructure/v2 v2.2.1
+ github.com/google/go-github/v71 v71.0.0 // indirect
github.com/google/go-querystring v1.1.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/mux v1.8.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
- github.com/moby/docker-image-spec v1.3.1 // indirect
- github.com/moby/term v0.5.0 // indirect
- github.com/morikuni/aec v1.0.0 // indirect
- github.com/opencontainers/go-digest v1.0.0 // indirect
- github.com/opencontainers/image-spec v1.1.1 // indirect
github.com/pelletier/go-toml/v2 v2.2.3 // indirect
- github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
+ github.com/rogpeppe/go-internal v1.13.1 // indirect
github.com/sagikazarmark/locafero v0.9.0 // indirect
+ github.com/shurcooL/githubv4 v0.0.0-20240727222349-48295856cce7
+ github.com/shurcooL/graphql v0.0.0-20230722043721-ed46e5a46466
github.com/sourcegraph/conc v0.3.0 // indirect
github.com/spf13/afero v1.14.0 // indirect
github.com/spf13/cast v1.7.1 // indirect
github.com/spf13/pflag v1.0.6 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
- go.opentelemetry.io/auto/sdk v1.1.0 // indirect
- go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 // indirect
- go.opentelemetry.io/otel v1.35.0 // indirect
- go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.35.0 // indirect
- go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0 // indirect
- go.opentelemetry.io/otel/metric v1.35.0 // indirect
- go.opentelemetry.io/otel/sdk v1.35.0 // indirect
- go.opentelemetry.io/otel/trace v1.35.0 // indirect
- go.opentelemetry.io/proto/otlp v1.5.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
+ golang.org/x/oauth2 v0.29.0 // indirect
golang.org/x/sys v0.31.0 // indirect
golang.org/x/text v0.23.0 // indirect
golang.org/x/time v0.5.0 // indirect
- google.golang.org/protobuf v1.36.5 // indirect
+ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
- gotest.tools/v3 v3.5.1 // indirect
)
diff --git a/go.sum b/go.sum
index 3378b4fd6..e7f6794a7 100644
--- a/go.sum
+++ b/go.sum
@@ -1,80 +1,59 @@
-github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 h1:UQHMgLO+TxOElx5B5HZ4hJQsoJ/PvUvKRhJHDQXO8P8=
-github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
-github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
-github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
-github.com/cenkalti/backoff/v4 v4.2.1 h1:y4OZtCnogmCPw98Zjyt5a6+QwPLGkiQsYW5oUqylYbM=
-github.com/cenkalti/backoff/v4 v4.2.1/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
-github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
-github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
+github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
-github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
-github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
-github.com/docker/docker v28.0.4+incompatible h1:JNNkBctYKurkw6FrHfKqY0nKIDf5nrbxjVBtS+cdcok=
-github.com/docker/docker v28.0.4+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
-github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c=
-github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc=
-github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
-github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
-github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
-github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M=
github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
-github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
-github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
-github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
-github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
-github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
+github.com/go-openapi/jsonpointer v0.19.5 h1:gZr+CIYByUqjcgeLXnQu2gHYQC9o73G2XUeOFYEICuY=
+github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
+github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk=
+github.com/go-openapi/swag v0.21.1 h1:wm0rhTb5z7qpJRHBdPOMuY4QjVUMbF6/kwoYeRAOrKU=
+github.com/go-openapi/swag v0.21.1/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ=
github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss=
github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
-github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
-github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
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/v64 v64.0.0 h1:4G61sozmY3eiPAjjoOHponXDBONm+utovTKbyUb2Qdg=
-github.com/google/go-github/v64 v64.0.0/go.mod h1:xB3vqMQNdHzilXBiO2I+M7iEFtHf+DP/omBOv6tQzVo=
-github.com/google/go-github/v69 v69.2.0 h1:wR+Wi/fN2zdUx9YxSmYE0ktiX9IAR/BeePzeaUUbEHE=
-github.com/google/go-github/v69 v69.2.0/go.mod h1:xne4jymxLR6Uj9b7J7PyTpkMYstEMMwGZa0Aehh1azM=
+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-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=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI=
github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
-github.com/grpc-ecosystem/grpc-gateway/v2 v2.25.1 h1:VNqngBF40hVlDloBruUehVYC3ArSgIyScOAyMRqBxRg=
-github.com/grpc-ecosystem/grpc-gateway/v2 v2.25.1/go.mod h1:RBRO7fro65R6tjKzYgLAFo0t1QEXY1Dp+i/bvpRiqiQ=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
-github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
-github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
+github.com/josephburnett/jd v1.9.2 h1:ECJRRFXCCqbtidkAHckHGSZm/JIaAxS1gygHLF8MI5Y=
+github.com/josephburnett/jd v1.9.2/go.mod h1:bImDr8QXpxMb3SD+w1cDRHp97xP6UwI88xUAuxwDQfM=
+github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
+github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
+github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
+github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
+github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
+github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
-github.com/mark3labs/mcp-go v0.20.1 h1:E1Bbx9K8d8kQmDZ1QHblM38c7UU2evQ2LlkANk1U/zw=
-github.com/mark3labs/mcp-go v0.20.1/go.mod h1:KmJndYv7GIgcPVwEKJjNcbhVQ+hJGJhrCCB/9xITzpE=
-github.com/migueleliasweb/go-github-mock v1.1.0 h1:GKaOBPsrPGkAKgtfuWY8MclS1xR6MInkx1SexJucMwE=
-github.com/migueleliasweb/go-github-mock v1.1.0/go.mod h1:pYe/XlGs4BGMfRY4vmeixVsODHnVDDhJ9zoi0qzSMHc=
-github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
-github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
-github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0=
-github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y=
-github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
-github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
-github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
-github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
-github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
-github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
+github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
+github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
+github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
+github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
+github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
+github.com/mark3labs/mcp-go v0.31.0 h1:4UxSV8aM770OPmTvaVe/b1rA2oZAjBMhGBfUgOGut+4=
+github.com/mark3labs/mcp-go v0.31.0/go.mod h1:rXqOudj/djTORU/ThxYx8fqEVj/5pvTuuebQ2RC7uk4=
+github.com/migueleliasweb/go-github-mock v1.3.0 h1:2sVP9JEMB2ubQw1IKto3/fzF51oFC6eVWOOFDgQoq88=
+github.com/migueleliasweb/go-github-mock v1.3.0/go.mod h1:ipQhV8fTcj/G6m7BKzin08GaJ/3B5/SonRAkgrk0zCY=
+github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
-github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
-github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
@@ -83,6 +62,10 @@ github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWN
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sagikazarmark/locafero v0.9.0 h1:GbgQGNtTrEmddYDSAH9QLRyfAHY12md+8YFTqyMTC9k=
github.com/sagikazarmark/locafero v0.9.0/go.mod h1:UBUyz37V+EdMS3hDF3QWIiVr/2dPrx49OMO0Bn0hJqk=
+github.com/shurcooL/githubv4 v0.0.0-20240727222349-48295856cce7 h1:cYCy18SHPKRkvclm+pWm1Lk4YrREb4IOIb/YdFO0p2M=
+github.com/shurcooL/githubv4 v0.0.0-20240727222349-48295856cce7/go.mod h1:zqMwyHmnN/eDOZOdiTohqIUKUrTFX62PNlu7IJdu0q8=
+github.com/shurcooL/graphql v0.0.0-20230722043721-ed46e5a46466 h1:17JxqqJY66GmZVHkmAsGEkcIu0oCe3AM420QDgGwZx0=
+github.com/shurcooL/graphql v0.0.0-20230722043721-ed46e5a46466/go.mod h1:9dIRpgIY7hVhoqfe0/FcYp0bpInZaT7dc3BYOprrIUE=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
@@ -98,6 +81,8 @@ github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An
github.com/spf13/viper v1.20.1 h1:ZMi+z/lvLyPSCoNtFCpqjy0S4kPbirhpTMwl8BkW9X4=
github.com/spf13/viper v1.20.1/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
+github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
@@ -105,75 +90,31 @@ github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
-github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
-github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
-go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
-go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
-go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 h1:jq9TW8u3so/bN+JPT166wjOI6/vQPF6Xe7nMNIltagk=
-go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0/go.mod h1:p8pYQP+m5XfbZm9fxtSKAbM6oIllS7s2AfxrChvc7iw=
-go.opentelemetry.io/otel v1.35.0 h1:xKWKPxrxB6OtMCbmMY021CqC45J+3Onta9MqjhnusiQ=
-go.opentelemetry.io/otel v1.35.0/go.mod h1:UEqy8Zp11hpkUrL73gSlELM0DupHoiq72dR+Zqel/+Y=
-go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.35.0 h1:1fTNlAIJZGWLP5FVu0fikVry1IsiUnXjf7QFvoNN3Xw=
-go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.35.0/go.mod h1:zjPK58DtkqQFn+YUMbx0M2XV3QgKU0gS9LeGohREyK4=
-go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0 h1:IeMeyr1aBvBiPVYihXIaeIZba6b8E1bYp7lbdxK8CQg=
-go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0/go.mod h1:oVdCUtjq9MK9BlS7TtucsQwUcXcymNiEDjgDD2jMtZU=
-go.opentelemetry.io/otel/metric v1.35.0 h1:0znxYu2SNyuMSQT4Y9WDWej0VpcsxkuklLa4/siN90M=
-go.opentelemetry.io/otel/metric v1.35.0/go.mod h1:nKVFgxBZ2fReX6IlyW28MgZojkoAkJGaE8CpgeAU3oE=
-go.opentelemetry.io/otel/sdk v1.35.0 h1:iPctf8iprVySXSKJffSS79eOjl9pvxV9ZqOWT0QejKY=
-go.opentelemetry.io/otel/sdk v1.35.0/go.mod h1:+ga1bZliga3DxJ3CQGg3updiaAJoNECOgJREo9KHGQg=
-go.opentelemetry.io/otel/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt/xgMs=
-go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc=
-go.opentelemetry.io/proto/otlp v1.5.0 h1:xJvq7gMzB31/d406fB8U5CBdyQGw4P399D1aQWU/3i4=
-go.opentelemetry.io/proto/otlp v1.5.0/go.mod h1:keN8WnHxOy8PG0rQZjJJ5A2ebUoafqWp0eVQ4yIXvJ4=
+github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82 h1:BHyfKlQyqbsFN5p3IfnEUduWvb9is428/nNb5L3U01M=
+github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82/go.mod h1:lgjkn3NuSvDfVJdfcVVdX+jpBxNmX4rDAzaS45IcYoM=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
-golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
-golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
-golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
-golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
-golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
-golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
-golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
-golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
-golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
-golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
-golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
-golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
-golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8=
+golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY=
+golang.org/x/oauth2 v0.29.0 h1:WdYw2tdTK1S8olAzWHdgeqfy+Mtm9XNhv/xJsY65d98=
+golang.org/x/oauth2 v0.29.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
-golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
-golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
-golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
-golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
-golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
-golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
-golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
-golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
-golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
-google.golang.org/genproto/googleapis/api v0.0.0-20250102185135-69823020774d h1:H8tOf8XM88HvKqLTxe755haY6r1fqqzLbEnfrmLXlSA=
-google.golang.org/genproto/googleapis/api v0.0.0-20250102185135-69823020774d/go.mod h1:2v7Z7gP2ZUOGsaFyxATQSRoBnKygqVq2Cwnvom7QiqY=
-google.golang.org/genproto/googleapis/rpc v0.0.0-20250102185135-69823020774d h1:xJJRGY7TJcvIlpSrN3K6LAWgNFUILlO+OMAqtg9aqnw=
-google.golang.org/genproto/googleapis/rpc v0.0.0-20250102185135-69823020774d/go.mod h1:3ENsm/5D1mzDyhpzeRi1NR784I0BcofWBoSc5QqqMK4=
-google.golang.org/grpc v1.69.2 h1:U3S9QEtbXC0bYNvRtcoklF3xGtLViumSYxWykJS+7AU=
-google.golang.org/grpc v1.69.2/go.mod h1:vyjdE6jLBI76dgpDojsFGNaHlxdjXN9ghpnd2o7JGZ4=
-google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM=
-google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
+gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
+gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
-gotest.tools/v3 v3.5.1 h1:EENdUnS3pdur5nybKYIh2Vfgc8IUNBjxDPSjtiJcOzU=
-gotest.tools/v3 v3.5.1/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU=
diff --git a/internal/ghmcp/server.go b/internal/ghmcp/server.go
new file mode 100644
index 000000000..ca38e76b3
--- /dev/null
+++ b/internal/ghmcp/server.go
@@ -0,0 +1,399 @@
+package ghmcp
+
+import (
+ "context"
+ "fmt"
+ "io"
+ "log"
+ "net/http"
+ "net/url"
+ "os"
+ "os/signal"
+ "strings"
+ "syscall"
+
+ "github.com/github/github-mcp-server/pkg/github"
+ 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"
+ "github.com/mark3labs/mcp-go/mcp"
+ "github.com/mark3labs/mcp-go/server"
+ "github.com/shurcooL/githubv4"
+ "github.com/sirupsen/logrus"
+)
+
+type MCPServerConfig struct {
+ // Version of the server
+ Version string
+
+ // GitHub Host to target for API requests (e.g. github.com or github.enterprise.com)
+ Host string
+
+ // GitHub Token to authenticate with the GitHub API
+ Token string
+
+ // EnabledToolsets is a list of toolsets to enable
+ // See: https://github.com/github/github-mcp-server?tab=readme-ov-file#tool-configuration
+ EnabledToolsets []string
+
+ // Whether to enable dynamic toolsets
+ // See: https://github.com/github/github-mcp-server?tab=readme-ov-file#dynamic-tool-discovery
+ DynamicToolsets bool
+
+ // ReadOnly indicates if we should only offer read-only tools
+ ReadOnly bool
+
+ // Translator provides translated text for the server tooling
+ Translator translations.TranslationHelperFunc
+}
+
+func NewMCPServer(cfg MCPServerConfig) (*server.MCPServer, error) {
+ apiHost, err := parseAPIHost(cfg.Host)
+ if err != nil {
+ return nil, fmt.Errorf("failed to parse API host: %w", err)
+ }
+
+ // Construct our REST client
+ restClient := gogithub.NewClient(nil).WithAuthToken(cfg.Token)
+ restClient.UserAgent = fmt.Sprintf("github-mcp-server/%s", cfg.Version)
+ restClient.BaseURL = apiHost.baseRESTURL
+ restClient.UploadURL = apiHost.uploadURL
+
+ // Construct our GraphQL client
+ // We're using NewEnterpriseClient here unconditionally as opposed to NewClient because we already
+ // did the necessary API host parsing so that github.com will return the correct URL anyway.
+ gqlHTTPClient := &http.Client{
+ Transport: &bearerAuthTransport{
+ transport: http.DefaultTransport,
+ token: cfg.Token,
+ },
+ } // We're going to wrap the Transport later in beforeInit
+ gqlClient := githubv4.NewEnterpriseClient(apiHost.graphqlURL.String(), gqlHTTPClient)
+
+ // When a client send an initialize request, update the user agent to include the client info.
+ beforeInit := func(_ context.Context, _ any, message *mcp.InitializeRequest) {
+ userAgent := fmt.Sprintf(
+ "github-mcp-server/%s (%s/%s)",
+ cfg.Version,
+ message.Params.ClientInfo.Name,
+ message.Params.ClientInfo.Version,
+ )
+
+ restClient.UserAgent = userAgent
+
+ gqlHTTPClient.Transport = &userAgentTransport{
+ transport: gqlHTTPClient.Transport,
+ agent: userAgent,
+ }
+ }
+
+ hooks := &server.Hooks{
+ OnBeforeInitialize: []server.OnBeforeInitializeFunc{beforeInit},
+ }
+
+ ghServer := github.NewServer(cfg.Version, server.WithHooks(hooks))
+
+ enabledToolsets := cfg.EnabledToolsets
+ if cfg.DynamicToolsets {
+ // filter "all" from the enabled toolsets
+ enabledToolsets = make([]string, 0, len(cfg.EnabledToolsets))
+ for _, toolset := range cfg.EnabledToolsets {
+ if toolset != "all" {
+ enabledToolsets = append(enabledToolsets, toolset)
+ }
+ }
+ }
+
+ getClient := func(_ context.Context) (*gogithub.Client, error) {
+ return restClient, nil // closing over client
+ }
+
+ getGQLClient := func(_ context.Context) (*githubv4.Client, error) {
+ return gqlClient, nil // closing over client
+ }
+
+ getRawClient := func(ctx context.Context) (*raw.Client, error) {
+ client, err := getClient(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get GitHub client: %w", err)
+ }
+ return raw.NewClient(client, apiHost.rawURL), nil // closing over client
+ }
+
+ // Create default toolsets
+ tsg := github.DefaultToolsetGroup(cfg.ReadOnly, getClient, getGQLClient, getRawClient, cfg.Translator)
+ err = tsg.EnableToolsets(enabledToolsets)
+
+ if err != nil {
+ return nil, fmt.Errorf("failed to enable toolsets: %w", err)
+ }
+
+ // Register all mcp functionality with the server
+ tsg.RegisterAll(ghServer)
+
+ if cfg.DynamicToolsets {
+ dynamic := github.InitDynamicToolset(ghServer, tsg, cfg.Translator)
+ dynamic.RegisterTools(ghServer)
+ }
+
+ return ghServer, nil
+}
+
+type StdioServerConfig struct {
+ // Version of the server
+ Version string
+
+ // GitHub Host to target for API requests (e.g. github.com or github.enterprise.com)
+ Host string
+
+ // GitHub Token to authenticate with the GitHub API
+ Token string
+
+ // EnabledToolsets is a list of toolsets to enable
+ // See: https://github.com/github/github-mcp-server?tab=readme-ov-file#tool-configuration
+ EnabledToolsets []string
+
+ // Whether to enable dynamic toolsets
+ // See: https://github.com/github/github-mcp-server?tab=readme-ov-file#dynamic-tool-discovery
+ DynamicToolsets bool
+
+ // ReadOnly indicates if we should only register read-only tools
+ ReadOnly bool
+
+ // ExportTranslations indicates if we should export translations
+ // See: https://github.com/github/github-mcp-server?tab=readme-ov-file#i18n--overriding-descriptions
+ ExportTranslations bool
+
+ // EnableCommandLogging indicates if we should log commands
+ EnableCommandLogging bool
+
+ // Path to the log file if not stderr
+ LogFilePath string
+}
+
+// RunStdioServer is not concurrent safe.
+func RunStdioServer(cfg StdioServerConfig) error {
+ // Create app context
+ ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
+ defer stop()
+
+ t, dumpTranslations := translations.TranslationHelper()
+
+ ghServer, err := NewMCPServer(MCPServerConfig{
+ Version: cfg.Version,
+ Host: cfg.Host,
+ Token: cfg.Token,
+ EnabledToolsets: cfg.EnabledToolsets,
+ DynamicToolsets: cfg.DynamicToolsets,
+ ReadOnly: cfg.ReadOnly,
+ Translator: t,
+ })
+ if err != nil {
+ return fmt.Errorf("failed to create MCP server: %w", err)
+ }
+
+ stdioServer := server.NewStdioServer(ghServer)
+
+ logrusLogger := logrus.New()
+ if cfg.LogFilePath != "" {
+ file, err := os.OpenFile(cfg.LogFilePath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0600)
+ if err != nil {
+ return fmt.Errorf("failed to open log file: %w", err)
+ }
+
+ logrusLogger.SetLevel(logrus.DebugLevel)
+ logrusLogger.SetOutput(file)
+ }
+ stdLogger := log.New(logrusLogger.Writer(), "stdioserver", 0)
+ stdioServer.SetErrorLogger(stdLogger)
+
+ if cfg.ExportTranslations {
+ // Once server is initialized, all translations are loaded
+ dumpTranslations()
+ }
+
+ // Start listening for messages
+ errC := make(chan error, 1)
+ go func() {
+ in, out := io.Reader(os.Stdin), io.Writer(os.Stdout)
+
+ if cfg.EnableCommandLogging {
+ loggedIO := mcplog.NewIOLogger(in, out, logrusLogger)
+ in, out = loggedIO, loggedIO
+ }
+
+ errC <- stdioServer.Listen(ctx, in, out)
+ }()
+
+ // Output github-mcp-server string
+ _, _ = fmt.Fprintf(os.Stderr, "GitHub MCP Server running on stdio\n")
+
+ // Wait for shutdown signal
+ select {
+ case <-ctx.Done():
+ logrusLogger.Infof("shutting down server...")
+ case err := <-errC:
+ if err != nil {
+ return fmt.Errorf("error running server: %w", err)
+ }
+ }
+
+ return nil
+}
+
+type apiHost struct {
+ baseRESTURL *url.URL
+ graphqlURL *url.URL
+ uploadURL *url.URL
+ rawURL *url.URL
+}
+
+func newDotcomHost() (apiHost, error) {
+ baseRestURL, err := url.Parse("https://api.github.com/")
+ if err != nil {
+ return apiHost{}, fmt.Errorf("failed to parse dotcom REST URL: %w", err)
+ }
+
+ gqlURL, err := url.Parse("https://api.github.com/graphql")
+ if err != nil {
+ return apiHost{}, fmt.Errorf("failed to parse dotcom GraphQL URL: %w", err)
+ }
+
+ uploadURL, err := url.Parse("https://uploads.github.com")
+ if err != nil {
+ return apiHost{}, fmt.Errorf("failed to parse dotcom Upload URL: %w", err)
+ }
+
+ rawURL, err := url.Parse("https://raw.githubusercontent.com/")
+ if err != nil {
+ return apiHost{}, fmt.Errorf("failed to parse dotcom Raw URL: %w", err)
+ }
+
+ return apiHost{
+ baseRESTURL: baseRestURL,
+ graphqlURL: gqlURL,
+ uploadURL: uploadURL,
+ rawURL: rawURL,
+ }, nil
+}
+
+func newGHECHost(hostname string) (apiHost, error) {
+ u, err := url.Parse(hostname)
+ if err != nil {
+ return apiHost{}, fmt.Errorf("failed to parse GHEC URL: %w", err)
+ }
+
+ // Unsecured GHEC would be an error
+ if u.Scheme == "http" {
+ return apiHost{}, fmt.Errorf("GHEC URL must be HTTPS")
+ }
+
+ restURL, err := url.Parse(fmt.Sprintf("https://api.%s/", u.Hostname()))
+ if err != nil {
+ return apiHost{}, fmt.Errorf("failed to parse GHEC REST URL: %w", err)
+ }
+
+ gqlURL, err := url.Parse(fmt.Sprintf("https://api.%s/graphql", u.Hostname()))
+ if err != nil {
+ return apiHost{}, fmt.Errorf("failed to parse GHEC GraphQL URL: %w", err)
+ }
+
+ uploadURL, err := url.Parse(fmt.Sprintf("https://uploads.%s", u.Hostname()))
+ if err != nil {
+ return apiHost{}, fmt.Errorf("failed to parse GHEC Upload URL: %w", err)
+ }
+
+ rawURL, err := url.Parse(fmt.Sprintf("https://raw.%s/", u.Hostname()))
+ if err != nil {
+ return apiHost{}, fmt.Errorf("failed to parse GHEC Raw URL: %w", err)
+ }
+
+ return apiHost{
+ baseRESTURL: restURL,
+ graphqlURL: gqlURL,
+ uploadURL: uploadURL,
+ rawURL: rawURL,
+ }, nil
+}
+
+func newGHESHost(hostname string) (apiHost, error) {
+ u, err := url.Parse(hostname)
+ if err != nil {
+ return apiHost{}, fmt.Errorf("failed to parse GHES URL: %w", err)
+ }
+
+ restURL, err := url.Parse(fmt.Sprintf("%s://%s/api/v3/", u.Scheme, u.Hostname()))
+ if err != nil {
+ return apiHost{}, fmt.Errorf("failed to parse GHES REST URL: %w", err)
+ }
+
+ gqlURL, err := url.Parse(fmt.Sprintf("%s://%s/api/graphql", u.Scheme, u.Hostname()))
+ if err != nil {
+ return apiHost{}, fmt.Errorf("failed to parse GHES GraphQL URL: %w", err)
+ }
+
+ uploadURL, err := url.Parse(fmt.Sprintf("%s://%s/api/uploads/", u.Scheme, u.Hostname()))
+ if err != nil {
+ return apiHost{}, fmt.Errorf("failed to parse GHES Upload URL: %w", err)
+ }
+ rawURL, err := url.Parse(fmt.Sprintf("%s://%s/raw/", u.Scheme, u.Hostname()))
+ if err != nil {
+ return apiHost{}, fmt.Errorf("failed to parse GHES Raw URL: %w", err)
+ }
+
+ return apiHost{
+ baseRESTURL: restURL,
+ graphqlURL: gqlURL,
+ uploadURL: uploadURL,
+ rawURL: rawURL,
+ }, nil
+}
+
+// Note that this does not handle ports yet, so development environments are out.
+func parseAPIHost(s string) (apiHost, error) {
+ if s == "" {
+ return newDotcomHost()
+ }
+
+ u, err := url.Parse(s)
+ if err != nil {
+ return apiHost{}, fmt.Errorf("could not parse host as URL: %s", s)
+ }
+
+ if u.Scheme == "" {
+ return apiHost{}, fmt.Errorf("host must have a scheme (http or https): %s", s)
+ }
+
+ if strings.HasSuffix(u.Hostname(), "github.com") {
+ return newDotcomHost()
+ }
+
+ if strings.HasSuffix(u.Hostname(), "ghe.com") {
+ return newGHECHost(s)
+ }
+
+ return newGHESHost(s)
+}
+
+type userAgentTransport struct {
+ transport http.RoundTripper
+ agent string
+}
+
+func (t *userAgentTransport) RoundTrip(req *http.Request) (*http.Response, error) {
+ req = req.Clone(req.Context())
+ req.Header.Set("User-Agent", t.agent)
+ return t.transport.RoundTrip(req)
+}
+
+type bearerAuthTransport struct {
+ transport http.RoundTripper
+ token string
+}
+
+func (t *bearerAuthTransport) RoundTrip(req *http.Request) (*http.Response, error) {
+ req = req.Clone(req.Context())
+ req.Header.Set("Authorization", "Bearer "+t.token)
+ return t.transport.RoundTrip(req)
+}
diff --git a/internal/githubv4mock/githubv4mock.go b/internal/githubv4mock/githubv4mock.go
new file mode 100644
index 000000000..03abc8e56
--- /dev/null
+++ b/internal/githubv4mock/githubv4mock.go
@@ -0,0 +1,218 @@
+// githubv4mock package provides a mock GraphQL server used for testing queries produced via
+// shurcooL/githubv4 or shurcooL/graphql modules.
+package githubv4mock
+
+import (
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+)
+
+type Matcher struct {
+ Request string
+ Variables map[string]any
+
+ Response GQLResponse
+}
+
+// NewQueryMatcher constructs a new matcher for the provided query and variables.
+// If the provided query is a string, it will be used-as-is, otherwise it will be
+// converted to a string using the constructQuery function taken from shurcooL/graphql.
+func NewQueryMatcher(query any, variables map[string]any, response GQLResponse) Matcher {
+ queryString, ok := query.(string)
+ if !ok {
+ queryString = constructQuery(query, variables)
+ }
+
+ return Matcher{
+ Request: queryString,
+ Variables: variables,
+ Response: response,
+ }
+}
+
+// NewMutationMatcher constructs a new matcher for the provided mutation and variables.
+// If the provided mutation is a string, it will be used-as-is, otherwise it will be
+// converted to a string using the constructMutation function taken from shurcooL/graphql.
+//
+// The input parameter is a special form of variable, matching the usage in shurcooL/githubv4. It will be added
+// to the query as a variable called `input`. Furthermore, it will be converted to a map[string]any
+// to be used for later equality comparison, as when the http handler is called, the request body will no longer
+// contain the input struct type information.
+func NewMutationMatcher(mutation any, input any, variables map[string]any, response GQLResponse) Matcher {
+ mutationString, ok := mutation.(string)
+ if !ok {
+ // Matching shurcooL/githubv4 mutation behaviour found in https://github.com/shurcooL/githubv4/blob/48295856cce734663ddbd790ff54800f784f3193/githubv4.go#L45-L56
+ if variables == nil {
+ variables = map[string]any{"input": input}
+ } else {
+ variables["input"] = input
+ }
+
+ mutationString = constructMutation(mutation, variables)
+ m, _ := githubv4InputStructToMap(input)
+ variables["input"] = m
+ }
+
+ return Matcher{
+ Request: mutationString,
+ Variables: variables,
+ Response: response,
+ }
+}
+
+type GQLResponse struct {
+ Data map[string]any `json:"data"`
+ Errors []struct {
+ Message string `json:"message"`
+ } `json:"errors,omitempty"`
+}
+
+// DataResponse is the happy path response constructor for a mocked GraphQL request.
+func DataResponse(data map[string]any) GQLResponse {
+ return GQLResponse{
+ Data: data,
+ }
+}
+
+// ErrorResponse is the unhappy path response constructor for a mocked GraphQL request.\
+// Note that for the moment it is only possible to return a single error message.
+func ErrorResponse(errorMsg string) GQLResponse {
+ return GQLResponse{
+ Errors: []struct {
+ Message string `json:"message"`
+ }{
+ {
+ Message: errorMsg,
+ },
+ },
+ }
+}
+
+// githubv4InputStructToMap converts a struct to a map[string]any, it uses JSON marshalling rather than reflection
+// to do so, because the json struct tags are used in the real implementation to produce the variable key names,
+// and we need to ensure that when variable matching occurs in the http handler, the keys correctly match.
+func githubv4InputStructToMap(s any) (map[string]any, error) {
+ jsonBytes, err := json.Marshal(s)
+ if err != nil {
+ return nil, err
+ }
+
+ var result map[string]any
+ err = json.Unmarshal(jsonBytes, &result)
+ return result, err
+}
+
+// NewMockedHTTPClient creates a new HTTP client that registers a handler for /graphql POST requests.
+// For each request, an attempt will be be made to match the request body against the provided matchers.
+// If a match is found, the corresponding response will be returned with StatusOK.
+//
+// Note that query and variable matching can be slightly fickle. The client expects an EXACT match on the query,
+// which in most cases will have been constructed from a type with graphql tags. The query construction code in
+// shurcooL/githubv4 uses the field types to derive the query string, thus a go string is not the same as a graphql.ID,
+// even though `type ID string`. It is therefore expected that matching variables have the right type for example:
+//
+// githubv4mock.NewQueryMatcher(
+// struct {
+// Repository struct {
+// PullRequest struct {
+// ID githubv4.ID
+// } `graphql:"pullRequest(number: $prNum)"`
+// } `graphql:"repository(owner: $owner, name: $repo)"`
+// }{},
+// map[string]any{
+// "owner": githubv4.String("owner"),
+// "repo": githubv4.String("repo"),
+// "prNum": githubv4.Int(42),
+// },
+// githubv4mock.DataResponse(
+// map[string]any{
+// "repository": map[string]any{
+// "pullRequest": map[string]any{
+// "id": "PR_kwDODKw3uc6WYN1T",
+// },
+// },
+// },
+// ),
+// )
+//
+// To aid in variable equality checks, values are considered equal if they approximate to the same type. This is
+// required because when the http handler is called, the request body no longer has the type information. This manifests
+// particularly when using the githubv4.Input types which have type deffed fields in their structs. For example:
+//
+// type CloseIssueInput struct {
+// IssueID ID `json:"issueId"`
+// StateReason *IssueClosedStateReason `json:"stateReason,omitempty"`
+// }
+//
+// This client does not currently provide a mechanism for out-of-band errors e.g. returning a 500,
+// and errors are constrained to GQL errors returned in the response body with a 200 status code.
+func NewMockedHTTPClient(ms ...Matcher) *http.Client {
+ matchers := make(map[string]Matcher, len(ms))
+ for _, m := range ms {
+ matchers[m.Request] = m
+ }
+
+ mux := http.NewServeMux()
+ mux.HandleFunc("/graphql", func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost {
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+
+ gqlRequest, err := parseBody(r.Body)
+ if err != nil {
+ http.Error(w, "invalid request body", http.StatusBadRequest)
+ return
+ }
+ defer func() { _ = r.Body.Close() }()
+
+ matcher, ok := matchers[gqlRequest.Query]
+ if !ok {
+ http.Error(w, fmt.Sprintf("no matcher found for query %s", gqlRequest.Query), http.StatusNotFound)
+ return
+ }
+
+ if len(gqlRequest.Variables) > 0 {
+ if len(gqlRequest.Variables) != len(matcher.Variables) {
+ http.Error(w, "variables do not have the same length", http.StatusBadRequest)
+ return
+ }
+
+ for k, v := range matcher.Variables {
+ if !objectsAreEqualValues(v, gqlRequest.Variables[k]) {
+ http.Error(w, "variable does not match", http.StatusBadRequest)
+ return
+ }
+ }
+ }
+
+ responseBody, err := json.Marshal(matcher.Response)
+ if err != nil {
+ http.Error(w, "error marshalling response", http.StatusInternalServerError)
+ return
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write(responseBody)
+ })
+
+ return &http.Client{Transport: &localRoundTripper{
+ handler: mux,
+ }}
+}
+
+type gqlRequest struct {
+ Query string `json:"query"`
+ Variables map[string]any `json:"variables,omitempty"`
+}
+
+func parseBody(r io.Reader) (gqlRequest, error) {
+ var req gqlRequest
+ err := json.NewDecoder(r).Decode(&req)
+ return req, err
+}
+
+func Ptr[T any](v T) *T { return &v }
diff --git a/internal/githubv4mock/local_round_tripper.go b/internal/githubv4mock/local_round_tripper.go
new file mode 100644
index 000000000..6be5f28fc
--- /dev/null
+++ b/internal/githubv4mock/local_round_tripper.go
@@ -0,0 +1,44 @@
+// Ths contents of this file are taken from https://github.com/shurcooL/graphql/blob/ed46e5a4646634fc16cb07c3b8db389542cc8847/graphql_test.go#L155-L165
+// because they are not exported by the module, and we would like to use them in building the githubv4mock test utility.
+//
+// The original license, copied from https://github.com/shurcooL/graphql/blob/ed46e5a4646634fc16cb07c3b8db389542cc8847/LICENSE
+//
+// MIT License
+
+// Copyright (c) 2017 Dmitri Shuralyov
+
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to deal
+// in the Software without restriction, including without limitation the rights
+// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+// copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+
+// The above copyright notice and this permission notice shall be included in all
+// copies or substantial portions of the Software.
+
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+// SOFTWARE.
+package githubv4mock
+
+import (
+ "net/http"
+ "net/http/httptest"
+)
+
+// localRoundTripper is an http.RoundTripper that executes HTTP transactions
+// by using handler directly, instead of going over an HTTP connection.
+type localRoundTripper struct {
+ handler http.Handler
+}
+
+func (l localRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
+ w := httptest.NewRecorder()
+ l.handler.ServeHTTP(w, req)
+ return w.Result(), nil
+}
diff --git a/internal/githubv4mock/objects_are_equal_values.go b/internal/githubv4mock/objects_are_equal_values.go
new file mode 100644
index 000000000..02ba7b39a
--- /dev/null
+++ b/internal/githubv4mock/objects_are_equal_values.go
@@ -0,0 +1,113 @@
+// The contents of this file are taken from https://github.com/stretchr/testify/blob/016e2e9c269209287f33ec203f340a9a723fe22c/assert/assertions.go#L166
+// because I do not want to take a dependency on the entire testify module just to use this equality check.
+//
+// There is a modification in objectsAreEqual to check that typed nils are equal, even if their types are different.
+//
+// The original license, copied from https://github.com/stretchr/testify/blob/016e2e9c269209287f33ec203f340a9a723fe22c/LICENSE
+//
+// MIT License
+//
+// Copyright (c) 2012-2020 Mat Ryer, Tyler Bunnell and contributors.
+
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to deal
+// in the Software without restriction, including without limitation the rights
+// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+// copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+
+// The above copyright notice and this permission notice shall be included in all
+// copies or substantial portions of the Software.
+
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+// SOFTWARE.
+package githubv4mock
+
+import (
+ "bytes"
+ "reflect"
+)
+
+func objectsAreEqualValues(expected, actual any) bool {
+ if objectsAreEqual(expected, actual) {
+ return true
+ }
+
+ expectedValue := reflect.ValueOf(expected)
+ actualValue := reflect.ValueOf(actual)
+ if !expectedValue.IsValid() || !actualValue.IsValid() {
+ return false
+ }
+
+ expectedType := expectedValue.Type()
+ actualType := actualValue.Type()
+ if !expectedType.ConvertibleTo(actualType) {
+ return false
+ }
+
+ if !isNumericType(expectedType) || !isNumericType(actualType) {
+ // Attempt comparison after type conversion
+ return reflect.DeepEqual(
+ expectedValue.Convert(actualType).Interface(), actual,
+ )
+ }
+
+ // If BOTH values are numeric, there are chances of false positives due
+ // to overflow or underflow. So, we need to make sure to always convert
+ // the smaller type to a larger type before comparing.
+ if expectedType.Size() >= actualType.Size() {
+ return actualValue.Convert(expectedType).Interface() == expected
+ }
+
+ return expectedValue.Convert(actualType).Interface() == actual
+}
+
+// objectsAreEqual determines if two objects are considered equal.
+//
+// This function does no assertion of any kind.
+func objectsAreEqual(expected, actual any) bool {
+ // There is a modification in objectsAreEqual to check that typed nils are equal, even if their types are different.
+ // This is required because when a nil is provided as a variable, the type is not known.
+ if isNil(expected) && isNil(actual) {
+ return true
+ }
+
+ exp, ok := expected.([]byte)
+ if !ok {
+ return reflect.DeepEqual(expected, actual)
+ }
+
+ act, ok := actual.([]byte)
+ if !ok {
+ return false
+ }
+ if exp == nil || act == nil {
+ return exp == nil && act == nil
+ }
+ return bytes.Equal(exp, act)
+}
+
+// isNumericType returns true if the type is one of:
+// int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64,
+// float32, float64, complex64, complex128
+func isNumericType(t reflect.Type) bool {
+ return t.Kind() >= reflect.Int && t.Kind() <= reflect.Complex128
+}
+
+func isNil(i any) bool {
+ if i == nil {
+ return true
+ }
+ v := reflect.ValueOf(i)
+ switch v.Kind() {
+ case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Pointer, reflect.Slice:
+ return v.IsNil()
+ default:
+ return false
+ }
+}
diff --git a/internal/githubv4mock/objects_are_equal_values_test.go b/internal/githubv4mock/objects_are_equal_values_test.go
new file mode 100644
index 000000000..d6839e794
--- /dev/null
+++ b/internal/githubv4mock/objects_are_equal_values_test.go
@@ -0,0 +1,73 @@
+// The contents of this file are taken from https://github.com/stretchr/testify/blob/016e2e9c269209287f33ec203f340a9a723fe22c/assert/assertions_test.go#L140-L174
+//
+// There is a modification to test objectsAreEqualValues to check that typed nils are equal, even if their types are different.
+
+// The original license, copied from https://github.com/stretchr/testify/blob/016e2e9c269209287f33ec203f340a9a723fe22c/LICENSE
+//
+// MIT License
+//
+// Copyright (c) 2012-2020 Mat Ryer, Tyler Bunnell and contributors.
+
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to deal
+// in the Software without restriction, including without limitation the rights
+// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+// copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+
+// The above copyright notice and this permission notice shall be included in all
+// copies or substantial portions of the Software.
+
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+// SOFTWARE.
+package githubv4mock
+
+import (
+ "fmt"
+ "math"
+ "testing"
+ "time"
+)
+
+func TestObjectsAreEqualValues(t *testing.T) {
+ now := time.Now()
+
+ cases := []struct {
+ expected interface{}
+ actual interface{}
+ result bool
+ }{
+ {uint32(10), int32(10), true},
+ {0, nil, false},
+ {nil, 0, false},
+ {now, now.In(time.Local), false}, // should not be time zone independent
+ {int(270), int8(14), false}, // should handle overflow/underflow
+ {int8(14), int(270), false},
+ {[]int{270, 270}, []int8{14, 14}, false},
+ {complex128(1e+100 + 1e+100i), complex64(complex(math.Inf(0), math.Inf(0))), false},
+ {complex64(complex(math.Inf(0), math.Inf(0))), complex128(1e+100 + 1e+100i), false},
+ {complex128(1e+100 + 1e+100i), 270, false},
+ {270, complex128(1e+100 + 1e+100i), false},
+ {complex128(1e+100 + 1e+100i), 3.14, false},
+ {3.14, complex128(1e+100 + 1e+100i), false},
+ {complex128(1e+10 + 1e+10i), complex64(1e+10 + 1e+10i), true},
+ {complex64(1e+10 + 1e+10i), complex128(1e+10 + 1e+10i), true},
+ {(*string)(nil), nil, true}, // typed nil vs untyped nil
+ {(*string)(nil), (*int)(nil), true}, // different typed nils
+ }
+
+ for _, c := range cases {
+ t.Run(fmt.Sprintf("ObjectsAreEqualValues(%#v, %#v)", c.expected, c.actual), func(t *testing.T) {
+ res := objectsAreEqualValues(c.expected, c.actual)
+
+ if res != c.result {
+ t.Errorf("ObjectsAreEqualValues(%#v, %#v) should return %#v", c.expected, c.actual, c.result)
+ }
+ })
+ }
+}
diff --git a/internal/githubv4mock/query.go b/internal/githubv4mock/query.go
new file mode 100644
index 000000000..7b265358d
--- /dev/null
+++ b/internal/githubv4mock/query.go
@@ -0,0 +1,157 @@
+// Ths contents of this file are taken from https://github.com/shurcooL/graphql/blob/ed46e5a4646634fc16cb07c3b8db389542cc8847/query.go
+// because they are not exported by the module, and we would like to use them in building the githubv4mock test utility.
+//
+// The original license, copied from https://github.com/shurcooL/graphql/blob/ed46e5a4646634fc16cb07c3b8db389542cc8847/LICENSE
+//
+// MIT License
+
+// Copyright (c) 2017 Dmitri Shuralyov
+
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to deal
+// in the Software without restriction, including without limitation the rights
+// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+// copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+
+// The above copyright notice and this permission notice shall be included in all
+// copies or substantial portions of the Software.
+
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+// SOFTWARE.
+package githubv4mock
+
+import (
+ "bytes"
+ "encoding/json"
+ "io"
+ "reflect"
+ "sort"
+
+ "github.com/shurcooL/graphql/ident"
+)
+
+func constructQuery(v any, variables map[string]any) string {
+ query := query(v)
+ if len(variables) > 0 {
+ return "query(" + queryArguments(variables) + ")" + query
+ }
+ return query
+}
+
+func constructMutation(v any, variables map[string]any) string {
+ query := query(v)
+ if len(variables) > 0 {
+ return "mutation(" + queryArguments(variables) + ")" + query
+ }
+ return "mutation" + query
+}
+
+// queryArguments constructs a minified arguments string for variables.
+//
+// E.g., map[string]any{"a": Int(123), "b": NewBoolean(true)} -> "$a:Int!$b:Boolean".
+func queryArguments(variables map[string]any) string {
+ // Sort keys in order to produce deterministic output for testing purposes.
+ // TODO: If tests can be made to work with non-deterministic output, then no need to sort.
+ keys := make([]string, 0, len(variables))
+ for k := range variables {
+ keys = append(keys, k)
+ }
+ sort.Strings(keys)
+
+ var buf bytes.Buffer
+ for _, k := range keys {
+ _, _ = io.WriteString(&buf, "$")
+ _, _ = io.WriteString(&buf, k)
+ _, _ = io.WriteString(&buf, ":")
+ writeArgumentType(&buf, reflect.TypeOf(variables[k]), true)
+ // Don't insert a comma here.
+ // Commas in GraphQL are insignificant, and we want minified output.
+ // See https://spec.graphql.org/October2021/#sec-Insignificant-Commas.
+ }
+ return buf.String()
+}
+
+// writeArgumentType writes a minified GraphQL type for t to w.
+// value indicates whether t is a value (required) type or pointer (optional) type.
+// If value is true, then "!" is written at the end of t.
+func writeArgumentType(w io.Writer, t reflect.Type, value bool) {
+ if t.Kind() == reflect.Ptr {
+ // Pointer is an optional type, so no "!" at the end of the pointer's underlying type.
+ writeArgumentType(w, t.Elem(), false)
+ return
+ }
+
+ switch t.Kind() {
+ case reflect.Slice, reflect.Array:
+ // List. E.g., "[Int]".
+ _, _ = io.WriteString(w, "[")
+ writeArgumentType(w, t.Elem(), true)
+ _, _ = io.WriteString(w, "]")
+ default:
+ // Named type. E.g., "Int".
+ name := t.Name()
+ if name == "string" { // HACK: Workaround for https://github.com/shurcooL/githubv4/issues/12.
+ name = "ID"
+ }
+ _, _ = io.WriteString(w, name)
+ }
+
+ if value {
+ // Value is a required type, so add "!" to the end.
+ _, _ = io.WriteString(w, "!")
+ }
+}
+
+// query uses writeQuery to recursively construct
+// a minified query string from the provided struct v.
+//
+// E.g., struct{Foo Int, BarBaz *Boolean} -> "{foo,barBaz}".
+func query(v any) string {
+ var buf bytes.Buffer
+ writeQuery(&buf, reflect.TypeOf(v), false)
+ return buf.String()
+}
+
+// writeQuery writes a minified query for t to w.
+// If inline is true, the struct fields of t are inlined into parent struct.
+func writeQuery(w io.Writer, t reflect.Type, inline bool) {
+ switch t.Kind() {
+ case reflect.Ptr, reflect.Slice:
+ writeQuery(w, t.Elem(), false)
+ case reflect.Struct:
+ // If the type implements json.Unmarshaler, it's a scalar. Don't expand it.
+ if reflect.PointerTo(t).Implements(jsonUnmarshaler) {
+ return
+ }
+ if !inline {
+ _, _ = io.WriteString(w, "{")
+ }
+ for i := 0; i < t.NumField(); i++ {
+ if i != 0 {
+ _, _ = io.WriteString(w, ",")
+ }
+ f := t.Field(i)
+ value, ok := f.Tag.Lookup("graphql")
+ inlineField := f.Anonymous && !ok
+ if !inlineField {
+ if ok {
+ _, _ = io.WriteString(w, value)
+ } else {
+ _, _ = io.WriteString(w, ident.ParseMixedCaps(f.Name).ToLowerCamelCase())
+ }
+ }
+ writeQuery(w, f.Type, inlineField)
+ }
+ if !inline {
+ _, _ = io.WriteString(w, "}")
+ }
+ }
+}
+
+var jsonUnmarshaler = reflect.TypeOf((*json.Unmarshaler)(nil)).Elem()
diff --git a/internal/toolsnaps/toolsnaps.go b/internal/toolsnaps/toolsnaps.go
new file mode 100644
index 000000000..f24ffe587
--- /dev/null
+++ b/internal/toolsnaps/toolsnaps.go
@@ -0,0 +1,81 @@
+// Package toolsnaps provides test utilities for ensuring json schemas for tools
+// have not changed unexpectedly.
+package toolsnaps
+
+import (
+ "encoding/json"
+ "fmt"
+ "os"
+ "path/filepath"
+
+ "github.com/josephburnett/jd/v2"
+)
+
+// Test checks that the JSON schema for a tool has not changed unexpectedly.
+// It compares the marshaled JSON of the provided tool against a stored snapshot file.
+// If the UPDATE_TOOLSNAPS environment variable is set to "true", it updates the snapshot file instead.
+// If the snapshot does not exist and not running in CI, it creates the snapshot file.
+// If the snapshot does not exist and running in CI (GITHUB_ACTIONS="true"), it returns an error.
+// If the snapshot exists, it compares the tool's JSON to the snapshot and returns an error if they differ.
+// Returns an error if marshaling, reading, or comparing fails.
+func Test(toolName string, tool any) error {
+ toolJSON, err := json.MarshalIndent(tool, "", " ")
+ if err != nil {
+ return fmt.Errorf("failed to marshal tool %s: %w", toolName, err)
+ }
+
+ snapPath := fmt.Sprintf("__toolsnaps__/%s.snap", toolName)
+
+ // If UPDATE_TOOLSNAPS is set, then we write the tool JSON to the snapshot file and exit
+ if os.Getenv("UPDATE_TOOLSNAPS") == "true" {
+ return writeSnap(snapPath, toolJSON)
+ }
+
+ snapJSON, err := os.ReadFile(snapPath) //nolint:gosec // filepaths are controlled by the test suite, so this is safe.
+ // If the snapshot file does not exist, this must be the first time this test is run.
+ // We write the tool JSON to the snapshot file and exit.
+ if os.IsNotExist(err) {
+ // If we're running in CI, we will error if there is not snapshot because it's important that snapshots
+ // are committed alongside the tests, rather than just being constructed and not committed during a CI run.
+ if os.Getenv("GITHUB_ACTIONS") == "true" {
+ return fmt.Errorf("tool snapshot does not exist for %s. Please run the tests with UPDATE_TOOLSNAPS=true to create it", toolName)
+ }
+
+ return writeSnap(snapPath, toolJSON)
+ }
+
+ // Otherwise we will compare the tool JSON to the snapshot JSON
+ toolNode, err := jd.ReadJsonString(string(toolJSON))
+ if err != nil {
+ return fmt.Errorf("failed to parse tool JSON for %s: %w", toolName, err)
+ }
+
+ snapNode, err := jd.ReadJsonString(string(snapJSON))
+ if err != nil {
+ return fmt.Errorf("failed to parse snapshot JSON for %s: %w", toolName, err)
+ }
+
+ // jd.Set allows arrays to be compared without order sensitivity,
+ // which is useful because we don't really care about this when exposing tool schemas.
+ diff := toolNode.Diff(snapNode, jd.SET).Render()
+ if diff != "" {
+ // If there is a difference, we return an error with the diff
+ return fmt.Errorf("tool schema for %s has changed unexpectedly:\n%s", toolName, diff)
+ }
+
+ return nil
+}
+
+func writeSnap(snapPath string, contents []byte) error {
+ // Ensure the directory exists
+ if err := os.MkdirAll(filepath.Dir(snapPath), 0700); err != nil {
+ return fmt.Errorf("failed to create snapshot directory: %w", err)
+ }
+
+ // Write the snapshot file
+ if err := os.WriteFile(snapPath, contents, 0600); err != nil {
+ return fmt.Errorf("failed to write snapshot file: %w", err)
+ }
+
+ return nil
+}
diff --git a/internal/toolsnaps/toolsnaps_test.go b/internal/toolsnaps/toolsnaps_test.go
new file mode 100644
index 000000000..c664911f0
--- /dev/null
+++ b/internal/toolsnaps/toolsnaps_test.go
@@ -0,0 +1,124 @@
+package toolsnaps
+
+import (
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+type dummyTool struct {
+ Name string `json:"name"`
+ Value int `json:"value"`
+}
+
+// withIsolatedWorkingDir creates a temp dir, changes to it, and restores the original working dir after the test.
+func withIsolatedWorkingDir(t *testing.T) {
+ dir := t.TempDir()
+ origDir, err := os.Getwd()
+ require.NoError(t, err)
+ t.Cleanup(func() { require.NoError(t, os.Chdir(origDir)) })
+ require.NoError(t, os.Chdir(dir))
+}
+
+func TestSnapshotDoesNotExistNotInCI(t *testing.T) {
+ withIsolatedWorkingDir(t)
+
+ // Given we are not running in CI
+ t.Setenv("GITHUB_ACTIONS", "false") // This REALLY is required because the tests run in CI
+ tool := dummyTool{"foo", 42}
+
+ // When we test the snapshot
+ err := Test("dummy", tool)
+
+ // Then it should succeed and write the snapshot file
+ require.NoError(t, err)
+ path := filepath.Join("__toolsnaps__", "dummy.snap")
+ _, statErr := os.Stat(path)
+ assert.NoError(t, statErr, "expected snapshot file to be written")
+}
+
+func TestSnapshotDoesNotExistInCI(t *testing.T) {
+ withIsolatedWorkingDir(t)
+
+ // Given we are running in CI
+ t.Setenv("GITHUB_ACTIONS", "true")
+ tool := dummyTool{"foo", 42}
+
+ // When we test the snapshot
+ err := Test("dummy", tool)
+
+ // Then it should error about missing snapshot in CI
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "tool snapshot does not exist", "expected error about missing snapshot in CI")
+}
+
+func TestSnapshotExistsMatch(t *testing.T) {
+ withIsolatedWorkingDir(t)
+
+ // Given a matching snapshot file exists
+ tool := dummyTool{"foo", 42}
+ b, _ := json.MarshalIndent(tool, "", " ")
+ require.NoError(t, os.MkdirAll("__toolsnaps__", 0700))
+ require.NoError(t, os.WriteFile(filepath.Join("__toolsnaps__", "dummy.snap"), b, 0600))
+
+ // When we test the snapshot
+ err := Test("dummy", tool)
+
+ // Then it should succeed (no error)
+ require.NoError(t, err)
+}
+
+func TestSnapshotExistsDiff(t *testing.T) {
+ withIsolatedWorkingDir(t)
+
+ // Given a non-matching snapshot file exists
+ require.NoError(t, os.MkdirAll("__toolsnaps__", 0700))
+ require.NoError(t, os.WriteFile(filepath.Join("__toolsnaps__", "dummy.snap"), []byte(`{"name":"foo","value":1}`), 0600))
+ tool := dummyTool{"foo", 2}
+
+ // When we test the snapshot
+ err := Test("dummy", tool)
+
+ // Then it should error about the schema diff
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "tool schema for dummy has changed unexpectedly", "expected error about diff")
+}
+
+func TestUpdateToolsnaps(t *testing.T) {
+ withIsolatedWorkingDir(t)
+
+ // Given UPDATE_TOOLSNAPS is set, regardless of whether a matching snapshot file exists
+ t.Setenv("UPDATE_TOOLSNAPS", "true")
+ require.NoError(t, os.MkdirAll("__toolsnaps__", 0700))
+ require.NoError(t, os.WriteFile(filepath.Join("__toolsnaps__", "dummy.snap"), []byte(`{"name":"foo","value":1}`), 0600))
+ tool := dummyTool{"foo", 42}
+
+ // When we test the snapshot
+ err := Test("dummy", tool)
+
+ // Then it should succeed and write the snapshot file
+ require.NoError(t, err)
+ path := filepath.Join("__toolsnaps__", "dummy.snap")
+ _, statErr := os.Stat(path)
+ assert.NoError(t, statErr, "expected snapshot file to be written")
+}
+
+func TestMalformedSnapshotJSON(t *testing.T) {
+ withIsolatedWorkingDir(t)
+
+ // Given a malformed snapshot file exists
+ require.NoError(t, os.MkdirAll("__toolsnaps__", 0700))
+ require.NoError(t, os.WriteFile(filepath.Join("__toolsnaps__", "dummy.snap"), []byte(`not-json`), 0600))
+ tool := dummyTool{"foo", 42}
+
+ // When we test the snapshot
+ err := Test("dummy", tool)
+
+ // Then it should error about malformed snapshot JSON
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "failed to parse snapshot JSON for dummy", "expected error about malformed snapshot JSON")
+}
diff --git a/pkg/github/__toolsnaps__/get_me.snap b/pkg/github/__toolsnaps__/get_me.snap
new file mode 100644
index 000000000..fc098f9d1
--- /dev/null
+++ b/pkg/github/__toolsnaps__/get_me.snap
@@ -0,0 +1,17 @@
+{
+ "annotations": {
+ "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.",
+ "inputSchema": {
+ "properties": {
+ "reason": {
+ "description": "Optional: the reason for requesting the user information",
+ "type": "string"
+ }
+ },
+ "type": "object"
+ },
+ "name": "get_me"
+}
\ No newline at end of file
diff --git a/pkg/github/code_scanning.go b/pkg/github/code_scanning.go
index b33f32c12..98714b6ce 100644
--- a/pkg/github/code_scanning.go
+++ b/pkg/github/code_scanning.go
@@ -8,7 +8,7 @@ import (
"net/http"
"github.com/github/github-mcp-server/pkg/translations"
- "github.com/google/go-github/v69/github"
+ "github.com/google/go-github/v72/github"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
)
@@ -16,6 +16,10 @@ import (
func GetCodeScanningAlert(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
return mcp.NewTool("get_code_scanning_alert",
mcp.WithDescription(t("TOOL_GET_CODE_SCANNING_ALERT_DESCRIPTION", "Get details of a specific code scanning alert in a GitHub repository.")),
+ mcp.WithToolAnnotation(mcp.ToolAnnotation{
+ Title: t("TOOL_GET_CODE_SCANNING_ALERT_USER_TITLE", "Get code scanning alert"),
+ ReadOnlyHint: ToBoolPtr(true),
+ }),
mcp.WithString("owner",
mcp.Required(),
mcp.Description("The owner of the repository."),
@@ -30,11 +34,11 @@ func GetCodeScanningAlert(getClient GetClientFn, t translations.TranslationHelpe
),
),
func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
- owner, err := requiredParam[string](request, "owner")
+ owner, err := RequiredParam[string](request, "owner")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
- repo, err := requiredParam[string](request, "repo")
+ repo, err := RequiredParam[string](request, "repo")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
@@ -74,6 +78,10 @@ func GetCodeScanningAlert(getClient GetClientFn, t translations.TranslationHelpe
func ListCodeScanningAlerts(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
return mcp.NewTool("list_code_scanning_alerts",
mcp.WithDescription(t("TOOL_LIST_CODE_SCANNING_ALERTS_DESCRIPTION", "List code scanning alerts in a GitHub repository.")),
+ mcp.WithToolAnnotation(mcp.ToolAnnotation{
+ Title: t("TOOL_LIST_CODE_SCANNING_ALERTS_USER_TITLE", "List code scanning alerts"),
+ ReadOnlyHint: ToBoolPtr(true),
+ }),
mcp.WithString("owner",
mcp.Required(),
mcp.Description("The owner of the repository."),
@@ -82,14 +90,14 @@ func ListCodeScanningAlerts(getClient GetClientFn, t translations.TranslationHel
mcp.Required(),
mcp.Description("The name of the repository."),
),
- mcp.WithString("ref",
- mcp.Description("The Git reference for the results you want to list."),
- ),
mcp.WithString("state",
mcp.Description("Filter code scanning alerts by state. Defaults to open"),
mcp.DefaultString("open"),
mcp.Enum("open", "closed", "dismissed", "fixed"),
),
+ mcp.WithString("ref",
+ mcp.Description("The Git reference for the results you want to list."),
+ ),
mcp.WithString("severity",
mcp.Description("Filter code scanning alerts by severity"),
mcp.Enum("critical", "high", "medium", "low", "warning", "note", "error"),
@@ -99,11 +107,11 @@ func ListCodeScanningAlerts(getClient GetClientFn, t translations.TranslationHel
),
),
func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
- owner, err := requiredParam[string](request, "owner")
+ owner, err := RequiredParam[string](request, "owner")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
- repo, err := requiredParam[string](request, "repo")
+ repo, err := RequiredParam[string](request, "repo")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
diff --git a/pkg/github/code_scanning_test.go b/pkg/github/code_scanning_test.go
index 40dabebdf..b5facbf6b 100644
--- a/pkg/github/code_scanning_test.go
+++ b/pkg/github/code_scanning_test.go
@@ -7,7 +7,7 @@ import (
"testing"
"github.com/github/github-mcp-server/pkg/translations"
- "github.com/google/go-github/v69/github"
+ "github.com/google/go-github/v72/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 1c91d7030..62a953de6 100644
--- a/pkg/github/context_tools.go
+++ b/pkg/github/context_tools.go
@@ -2,10 +2,6 @@ package github
import (
"context"
- "encoding/json"
- "fmt"
- "io"
- "net/http"
"github.com/github/github-mcp-server/pkg/translations"
"github.com/mark3labs/mcp-go/mcp"
@@ -13,37 +9,32 @@ import (
)
// GetMe creates a tool to get details of the authenticated user.
-func GetMe(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
- return mcp.NewTool("get_me",
- mcp.WithDescription(t("TOOL_GET_ME_DESCRIPTION", "Get details of the authenticated GitHub user. Use this when a request include \"me\", \"my\"...")),
- mcp.WithString("reason",
- mcp.Description("Optional: reason the session was created"),
- ),
+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.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"),
),
- func(ctx context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) {
- client, err := getClient(ctx)
- if err != nil {
- return nil, fmt.Errorf("failed to get GitHub client: %w", err)
- }
- user, resp, err := client.Users.Get(ctx, "")
- if err != nil {
- return nil, fmt.Errorf("failed to get user: %w", err)
- }
- defer func() { _ = resp.Body.Close() }()
+ )
- if resp.StatusCode != http.StatusOK {
- body, err := io.ReadAll(resp.Body)
- if err != nil {
- return nil, fmt.Errorf("failed to read response body: %w", err)
- }
- return mcp.NewToolResultError(fmt.Sprintf("failed to get user: %s", string(body))), nil
- }
-
- r, err := json.Marshal(user)
- if err != nil {
- return nil, fmt.Errorf("failed to marshal user: %w", err)
- }
+ type args struct{}
+ handler := mcp.NewTypedToolHandler(func(ctx context.Context, _ mcp.CallToolRequest, _ args) (*mcp.CallToolResult, error) {
+ client, err := getClient(ctx)
+ if err != nil {
+ return mcp.NewToolResultErrorFromErr("failed to get GitHub client", err), nil
+ }
- return mcp.NewToolResultText(string(r)), nil
+ user, _, err := client.Users.Get(ctx, "")
+ if err != nil {
+ return mcp.NewToolResultErrorFromErr("failed to get user", err), nil
}
+
+ return MarshalledTextResult(user), nil
+ })
+
+ return tool, handler
}
diff --git a/pkg/github/context_tools_test.go b/pkg/github/context_tools_test.go
index c9d220dd9..0d9193976 100644
--- a/pkg/github/context_tools_test.go
+++ b/pkg/github/context_tools_test.go
@@ -3,26 +3,26 @@ package github
import (
"context"
"encoding/json"
- "net/http"
"testing"
"time"
+ "github.com/github/github-mcp-server/internal/toolsnaps"
"github.com/github/github-mcp-server/pkg/translations"
- "github.com/google/go-github/v69/github"
+ "github.com/google/go-github/v72/github"
"github.com/migueleliasweb/go-github-mock/src/mock"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func Test_GetMe(t *testing.T) {
- // Verify tool definition
- mockClient := github.NewClient(nil)
- tool, _ := GetMe(stubGetClientFn(mockClient), translations.NullTranslationHelper)
+ t.Parallel()
+ tool, _ := GetMe(nil, translations.NullTranslationHelper)
+ require.NoError(t, toolsnaps.Test(tool.Name, tool))
+
+ // Verify some basic very important properties
assert.Equal(t, "get_me", tool.Name)
- assert.NotEmpty(t, tool.Description)
- assert.Contains(t, tool.InputSchema.Properties, "reason")
- assert.Empty(t, tool.InputSchema.Required) // No required parameters
+ assert.True(t, *tool.Annotations.ReadOnlyHint, "get_me tool should be read-only")
// Setup mock user response
mockUser := &github.User{
@@ -41,80 +41,81 @@ func Test_GetMe(t *testing.T) {
}
tests := []struct {
- name string
- mockedClient *http.Client
- requestArgs map[string]interface{}
- expectError bool
- expectedUser *github.User
- expectedErrMsg string
+ name string
+ stubbedGetClientFn GetClientFn
+ requestArgs map[string]any
+ expectToolError bool
+ expectedUser *github.User
+ expectedToolErrMsg string
}{
{
name: "successful get user",
- mockedClient: mock.NewMockedHTTPClient(
- mock.WithRequestMatch(
- mock.GetUser,
- mockUser,
+ stubbedGetClientFn: stubGetClientFromHTTPFn(
+ mock.NewMockedHTTPClient(
+ mock.WithRequestMatch(
+ mock.GetUser,
+ mockUser,
+ ),
),
),
- requestArgs: map[string]interface{}{},
- expectError: false,
- expectedUser: mockUser,
+ requestArgs: map[string]any{},
+ expectToolError: false,
+ expectedUser: mockUser,
},
{
name: "successful get user with reason",
- mockedClient: mock.NewMockedHTTPClient(
- mock.WithRequestMatch(
- mock.GetUser,
- mockUser,
+ stubbedGetClientFn: stubGetClientFromHTTPFn(
+ mock.NewMockedHTTPClient(
+ mock.WithRequestMatch(
+ mock.GetUser,
+ mockUser,
+ ),
),
),
- requestArgs: map[string]interface{}{
+ requestArgs: map[string]any{
"reason": "Testing API",
},
- expectError: false,
- expectedUser: mockUser,
+ expectToolError: false,
+ expectedUser: mockUser,
+ },
+ {
+ name: "getting client fails",
+ stubbedGetClientFn: stubGetClientFnErr("expected test error"),
+ requestArgs: map[string]any{},
+ expectToolError: true,
+ expectedToolErrMsg: "failed to get GitHub client: expected test error",
},
{
name: "get user fails",
- mockedClient: mock.NewMockedHTTPClient(
- mock.WithRequestMatchHandler(
- mock.GetUser,
- http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
- w.WriteHeader(http.StatusUnauthorized)
- _, _ = w.Write([]byte(`{"message": "Unauthorized"}`))
- }),
+ stubbedGetClientFn: stubGetClientFromHTTPFn(
+ mock.NewMockedHTTPClient(
+ mock.WithRequestMatchHandler(
+ mock.GetUser,
+ badRequestHandler("expected test failure"),
+ ),
),
),
- requestArgs: map[string]interface{}{},
- expectError: true,
- expectedErrMsg: "failed to get user",
+ requestArgs: map[string]any{},
+ expectToolError: true,
+ expectedToolErrMsg: "expected test failure",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
- // Setup client with mock
- client := github.NewClient(tc.mockedClient)
- _, handler := GetMe(stubGetClientFn(client), translations.NullTranslationHelper)
+ _, handler := GetMe(tc.stubbedGetClientFn, translations.NullTranslationHelper)
- // Create call request
request := createMCPRequest(tc.requestArgs)
-
- // Call handler
result, err := handler(context.Background(), request)
+ require.NoError(t, err)
+ textContent := getTextResult(t, result)
- // Verify results
- if tc.expectError {
- require.Error(t, err)
- assert.Contains(t, err.Error(), tc.expectedErrMsg)
+ if tc.expectToolError {
+ assert.True(t, result.IsError, "expected tool call result to be an error")
+ assert.Contains(t, textContent.Text, tc.expectedToolErrMsg)
return
}
- require.NoError(t, err)
-
- // Parse result and get text content if no error
- textContent := getTextResult(t, result)
-
// Unmarshal and verify the result
var returnedUser github.User
err = json.Unmarshal([]byte(textContent.Text), &returnedUser)
diff --git a/pkg/github/dynamic_tools.go b/pkg/github/dynamic_tools.go
index d4d5f27a6..e703a885e 100644
--- a/pkg/github/dynamic_tools.go
+++ b/pkg/github/dynamic_tools.go
@@ -22,6 +22,11 @@ func ToolsetEnum(toolsetGroup *toolsets.ToolsetGroup) mcp.PropertyOption {
func EnableToolset(s *server.MCPServer, toolsetGroup *toolsets.ToolsetGroup, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
return mcp.NewTool("enable_toolset",
mcp.WithDescription(t("TOOL_ENABLE_TOOLSET_DESCRIPTION", "Enable one of the sets of tools the GitHub MCP server provides, use get_toolset_tools and list_available_toolsets first to see what this will enable")),
+ mcp.WithToolAnnotation(mcp.ToolAnnotation{
+ Title: t("TOOL_ENABLE_TOOLSET_USER_TITLE", "Enable a toolset"),
+ // Not modifying GitHub data so no need to show a warning
+ ReadOnlyHint: ToBoolPtr(true),
+ }),
mcp.WithString("toolset",
mcp.Required(),
mcp.Description("The name of the toolset to enable"),
@@ -30,7 +35,7 @@ func EnableToolset(s *server.MCPServer, toolsetGroup *toolsets.ToolsetGroup, t t
),
func(_ context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
// We need to convert the toolsets back to a map for JSON serialization
- toolsetName, err := requiredParam[string](request, "toolset")
+ toolsetName, err := RequiredParam[string](request, "toolset")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
@@ -57,6 +62,10 @@ func EnableToolset(s *server.MCPServer, toolsetGroup *toolsets.ToolsetGroup, t t
func ListAvailableToolsets(toolsetGroup *toolsets.ToolsetGroup, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
return mcp.NewTool("list_available_toolsets",
mcp.WithDescription(t("TOOL_LIST_AVAILABLE_TOOLSETS_DESCRIPTION", "List all available toolsets this GitHub MCP server can offer, providing the enabled status of each. Use this when a task could be achieved with a GitHub tool and the currently available tools aren't enough. Call get_toolset_tools with these toolset names to discover specific tools you can call")),
+ mcp.WithToolAnnotation(mcp.ToolAnnotation{
+ Title: t("TOOL_LIST_AVAILABLE_TOOLSETS_USER_TITLE", "List available toolsets"),
+ ReadOnlyHint: ToBoolPtr(true),
+ }),
),
func(_ context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) {
// We need to convert the toolsetGroup back to a map for JSON serialization
@@ -87,6 +96,10 @@ func ListAvailableToolsets(toolsetGroup *toolsets.ToolsetGroup, t translations.T
func GetToolsetsTools(toolsetGroup *toolsets.ToolsetGroup, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
return mcp.NewTool("get_toolset_tools",
mcp.WithDescription(t("TOOL_GET_TOOLSET_TOOLS_DESCRIPTION", "Lists all the capabilities that are enabled with the specified toolset, use this to get clarity on whether enabling a toolset would help you to complete a task")),
+ mcp.WithToolAnnotation(mcp.ToolAnnotation{
+ Title: t("TOOL_GET_TOOLSET_TOOLS_USER_TITLE", "List all tools in a toolset"),
+ ReadOnlyHint: ToBoolPtr(true),
+ }),
mcp.WithString("toolset",
mcp.Required(),
mcp.Description("The name of the toolset you want to get the tools for"),
@@ -95,7 +108,7 @@ func GetToolsetsTools(toolsetGroup *toolsets.ToolsetGroup, t translations.Transl
),
func(_ context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
// We need to convert the toolsetGroup back to a map for JSON serialization
- toolsetName, err := requiredParam[string](request, "toolset")
+ toolsetName, err := RequiredParam[string](request, "toolset")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
diff --git a/pkg/github/helper_test.go b/pkg/github/helper_test.go
index 40fc0b944..bc1ae412f 100644
--- a/pkg/github/helper_test.go
+++ b/pkg/github/helper_test.go
@@ -10,6 +10,32 @@ import (
"github.com/stretchr/testify/require"
)
+type expectations struct {
+ path string
+ queryParams map[string]string
+ requestBody any
+}
+
+// expect is a helper function to create a partial mock that expects various
+// request behaviors, such as path, query parameters, and request body.
+func expect(t *testing.T, e expectations) *partialMock {
+ return &partialMock{
+ t: t,
+ expectedPath: e.path,
+ expectedQueryParams: e.queryParams,
+ expectedRequestBody: e.requestBody,
+ }
+}
+
+// expectPath is a helper function to create a partial mock that expects a
+// request with the given path, with the ability to chain a response handler.
+func expectPath(t *testing.T, expectedPath string) *partialMock {
+ return &partialMock{
+ t: t,
+ expectedPath: expectedPath,
+ }
+}
+
// expectQueryParams is a helper function to create a partial mock that expects a
// request with the given query parameters, with the ability to chain a response handler.
func expectQueryParams(t *testing.T, expectedQueryParams map[string]string) *partialMock {
@@ -29,7 +55,9 @@ func expectRequestBody(t *testing.T, expectedRequestBody any) *partialMock {
}
type partialMock struct {
- t *testing.T
+ t *testing.T
+
+ expectedPath string
expectedQueryParams map[string]string
expectedRequestBody any
}
@@ -37,12 +65,8 @@ type partialMock struct {
func (p *partialMock) andThen(responseHandler http.HandlerFunc) http.HandlerFunc {
p.t.Helper()
return func(w http.ResponseWriter, r *http.Request) {
- if p.expectedRequestBody != nil {
- var unmarshaledRequestBody any
- err := json.NewDecoder(r.Body).Decode(&unmarshaledRequestBody)
- require.NoError(p.t, err)
-
- require.Equal(p.t, p.expectedRequestBody, unmarshaledRequestBody)
+ if p.expectedPath != "" {
+ require.Equal(p.t, p.expectedPath, r.URL.Path)
}
if p.expectedQueryParams != nil {
@@ -52,6 +76,14 @@ func (p *partialMock) andThen(responseHandler http.HandlerFunc) http.HandlerFunc
}
}
+ if p.expectedRequestBody != nil {
+ var unmarshaledRequestBody any
+ err := json.NewDecoder(r.Body).Decode(&unmarshaledRequestBody)
+ require.NoError(p.t, err)
+
+ require.Equal(p.t, p.expectedRequestBody, unmarshaledRequestBody)
+ }
+
responseHandler(w, r)
}
}
@@ -62,6 +94,14 @@ func mockResponse(t *testing.T, code int, body interface{}) http.HandlerFunc {
t.Helper()
return func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(code)
+ // Some tests do not expect to return a JSON object, such as fetching a raw pull request diff,
+ // so allow strings to be returned directly.
+ s, ok := body.(string)
+ if ok {
+ _, _ = w.Write([]byte(s))
+ return
+ }
+
b, err := json.Marshal(body)
require.NoError(t, err)
_, _ = w.Write(b)
@@ -69,14 +109,12 @@ func mockResponse(t *testing.T, code int, body interface{}) http.HandlerFunc {
}
// createMCPRequest is a helper function to create a MCP request with the given arguments.
-func createMCPRequest(args map[string]interface{}) mcp.CallToolRequest {
+func createMCPRequest(args any) mcp.CallToolRequest {
return mcp.CallToolRequest{
Params: struct {
- Name string `json:"name"`
- Arguments map[string]interface{} `json:"arguments,omitempty"`
- Meta *struct {
- ProgressToken mcp.ProgressToken `json:"progressToken,omitempty"`
- } `json:"_meta,omitempty"`
+ Name string `json:"name"`
+ Arguments any `json:"arguments,omitempty"`
+ Meta *mcp.Meta `json:"_meta,omitempty"`
}{
Arguments: args,
},
@@ -94,6 +132,36 @@ func getTextResult(t *testing.T, result *mcp.CallToolResult) mcp.TextContent {
return textContent
}
+func getErrorResult(t *testing.T, result *mcp.CallToolResult) mcp.TextContent {
+ res := getTextResult(t, result)
+ require.True(t, result.IsError, "expected tool call result to be an error")
+ return res
+}
+
+// getTextResourceResult is a helper function that returns a text result from a tool call.
+func getTextResourceResult(t *testing.T, result *mcp.CallToolResult) mcp.TextResourceContents {
+ t.Helper()
+ assert.NotNil(t, result)
+ require.Len(t, result.Content, 2)
+ content := result.Content[1]
+ require.IsType(t, mcp.EmbeddedResource{}, content)
+ resource := content.(mcp.EmbeddedResource)
+ require.IsType(t, mcp.TextResourceContents{}, resource.Resource)
+ return resource.Resource.(mcp.TextResourceContents)
+}
+
+// getBlobResourceResult is a helper function that returns a blob result from a tool call.
+func getBlobResourceResult(t *testing.T, result *mcp.CallToolResult) mcp.BlobResourceContents {
+ t.Helper()
+ assert.NotNil(t, result)
+ require.Len(t, result.Content, 2)
+ content := result.Content[1]
+ require.IsType(t, mcp.EmbeddedResource{}, content)
+ resource := content.(mcp.EmbeddedResource)
+ require.IsType(t, mcp.BlobResourceContents{}, resource.Resource)
+ return resource.Resource.(mcp.BlobResourceContents)
+}
+
func TestOptionalParamOK(t *testing.T) {
tests := []struct {
name string
diff --git a/pkg/github/issues.go b/pkg/github/issues.go
index 1324bd568..ea068ed00 100644
--- a/pkg/github/issues.go
+++ b/pkg/github/issues.go
@@ -6,18 +6,25 @@ import (
"fmt"
"io"
"net/http"
+ "strings"
"time"
"github.com/github/github-mcp-server/pkg/translations"
- "github.com/google/go-github/v69/github"
+ "github.com/go-viper/mapstructure/v2"
+ "github.com/google/go-github/v72/github"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
+ "github.com/shurcooL/githubv4"
)
// GetIssue creates a tool to get details of a specific issue in a GitHub repository.
func GetIssue(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
return mcp.NewTool("get_issue",
- mcp.WithDescription(t("TOOL_GET_ISSUE_DESCRIPTION", "Get details of a specific issue in a GitHub repository")),
+ mcp.WithDescription(t("TOOL_GET_ISSUE_DESCRIPTION", "Get details of a specific issue in a GitHub repository.")),
+ mcp.WithToolAnnotation(mcp.ToolAnnotation{
+ Title: t("TOOL_GET_ISSUE_USER_TITLE", "Get issue details"),
+ ReadOnlyHint: ToBoolPtr(true),
+ }),
mcp.WithString("owner",
mcp.Required(),
mcp.Description("The owner of the repository"),
@@ -32,11 +39,11 @@ func GetIssue(getClient GetClientFn, t translations.TranslationHelperFunc) (tool
),
),
func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
- owner, err := requiredParam[string](request, "owner")
+ owner, err := RequiredParam[string](request, "owner")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
- repo, err := requiredParam[string](request, "repo")
+ repo, err := RequiredParam[string](request, "repo")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
@@ -75,7 +82,11 @@ func GetIssue(getClient GetClientFn, t translations.TranslationHelperFunc) (tool
// AddIssueComment creates a tool to add a comment to an issue.
func AddIssueComment(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
return mcp.NewTool("add_issue_comment",
- mcp.WithDescription(t("TOOL_ADD_ISSUE_COMMENT_DESCRIPTION", "Add a comment to an existing issue")),
+ mcp.WithDescription(t("TOOL_ADD_ISSUE_COMMENT_DESCRIPTION", "Add a comment to a specific issue in a GitHub repository.")),
+ mcp.WithToolAnnotation(mcp.ToolAnnotation{
+ Title: t("TOOL_ADD_ISSUE_COMMENT_USER_TITLE", "Add comment to issue"),
+ ReadOnlyHint: ToBoolPtr(false),
+ }),
mcp.WithString("owner",
mcp.Required(),
mcp.Description("Repository owner"),
@@ -94,11 +105,11 @@ func AddIssueComment(getClient GetClientFn, t translations.TranslationHelperFunc
),
),
func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
- owner, err := requiredParam[string](request, "owner")
+ owner, err := RequiredParam[string](request, "owner")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
- repo, err := requiredParam[string](request, "repo")
+ repo, err := RequiredParam[string](request, "repo")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
@@ -106,7 +117,7 @@ func AddIssueComment(getClient GetClientFn, t translations.TranslationHelperFunc
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
- body, err := requiredParam[string](request, "body")
+ body, err := RequiredParam[string](request, "body")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
@@ -145,7 +156,11 @@ func AddIssueComment(getClient GetClientFn, t translations.TranslationHelperFunc
// SearchIssues creates a tool to search for issues and pull requests.
func SearchIssues(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
return mcp.NewTool("search_issues",
- mcp.WithDescription(t("TOOL_SEARCH_ISSUES_DESCRIPTION", "Search for issues and pull requests across GitHub repositories")),
+ mcp.WithDescription(t("TOOL_SEARCH_ISSUES_DESCRIPTION", "Search for issues in GitHub repositories.")),
+ mcp.WithToolAnnotation(mcp.ToolAnnotation{
+ Title: t("TOOL_SEARCH_ISSUES_USER_TITLE", "Search issues"),
+ ReadOnlyHint: ToBoolPtr(true),
+ }),
mcp.WithString("q",
mcp.Required(),
mcp.Description("Search query using GitHub issues search syntax"),
@@ -173,7 +188,7 @@ func SearchIssues(getClient GetClientFn, t translations.TranslationHelperFunc) (
WithPagination(),
),
func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
- query, err := requiredParam[string](request, "q")
+ query, err := RequiredParam[string](request, "q")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
@@ -229,7 +244,11 @@ func SearchIssues(getClient GetClientFn, t translations.TranslationHelperFunc) (
// CreateIssue creates a tool to create a new issue in a GitHub repository.
func CreateIssue(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
return mcp.NewTool("create_issue",
- mcp.WithDescription(t("TOOL_CREATE_ISSUE_DESCRIPTION", "Create a new issue in a GitHub repository")),
+ mcp.WithDescription(t("TOOL_CREATE_ISSUE_DESCRIPTION", "Create a new issue in a GitHub repository.")),
+ mcp.WithToolAnnotation(mcp.ToolAnnotation{
+ Title: t("TOOL_CREATE_ISSUE_USER_TITLE", "Open new issue"),
+ ReadOnlyHint: ToBoolPtr(false),
+ }),
mcp.WithString("owner",
mcp.Required(),
mcp.Description("Repository owner"),
@@ -248,7 +267,7 @@ func CreateIssue(getClient GetClientFn, t translations.TranslationHelperFunc) (t
mcp.WithArray("assignees",
mcp.Description("Usernames to assign to this issue"),
mcp.Items(
- map[string]interface{}{
+ map[string]any{
"type": "string",
},
),
@@ -256,7 +275,7 @@ func CreateIssue(getClient GetClientFn, t translations.TranslationHelperFunc) (t
mcp.WithArray("labels",
mcp.Description("Labels to apply to this issue"),
mcp.Items(
- map[string]interface{}{
+ map[string]any{
"type": "string",
},
),
@@ -266,15 +285,15 @@ func CreateIssue(getClient GetClientFn, t translations.TranslationHelperFunc) (t
),
),
func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
- owner, err := requiredParam[string](request, "owner")
+ owner, err := RequiredParam[string](request, "owner")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
- repo, err := requiredParam[string](request, "repo")
+ repo, err := RequiredParam[string](request, "repo")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
- title, err := requiredParam[string](request, "title")
+ title, err := RequiredParam[string](request, "title")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
@@ -347,7 +366,11 @@ func CreateIssue(getClient GetClientFn, t translations.TranslationHelperFunc) (t
// ListIssues creates a tool to list and filter repository issues
func ListIssues(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
return mcp.NewTool("list_issues",
- mcp.WithDescription(t("TOOL_LIST_ISSUES_DESCRIPTION", "List issues in a GitHub repository with filtering options")),
+ mcp.WithDescription(t("TOOL_LIST_ISSUES_DESCRIPTION", "List issues in a GitHub repository.")),
+ mcp.WithToolAnnotation(mcp.ToolAnnotation{
+ Title: t("TOOL_LIST_ISSUES_USER_TITLE", "List issues"),
+ ReadOnlyHint: ToBoolPtr(true),
+ }),
mcp.WithString("owner",
mcp.Required(),
mcp.Description("Repository owner"),
@@ -382,11 +405,11 @@ func ListIssues(getClient GetClientFn, t translations.TranslationHelperFunc) (to
WithPagination(),
),
func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
- owner, err := requiredParam[string](request, "owner")
+ owner, err := RequiredParam[string](request, "owner")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
- repo, err := requiredParam[string](request, "repo")
+ repo, err := RequiredParam[string](request, "repo")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
@@ -427,12 +450,12 @@ func ListIssues(getClient GetClientFn, t translations.TranslationHelperFunc) (to
opts.Since = timestamp
}
- if page, ok := request.Params.Arguments["page"].(float64); ok {
- opts.Page = int(page)
+ if page, ok := request.GetArguments()["page"].(float64); ok {
+ opts.ListOptions.Page = int(page)
}
- if perPage, ok := request.Params.Arguments["perPage"].(float64); ok {
- opts.PerPage = int(perPage)
+ if perPage, ok := request.GetArguments()["perPage"].(float64); ok {
+ opts.ListOptions.PerPage = int(perPage)
}
client, err := getClient(ctx)
@@ -465,7 +488,11 @@ func ListIssues(getClient GetClientFn, t translations.TranslationHelperFunc) (to
// UpdateIssue creates a tool to update an existing issue in a GitHub repository.
func UpdateIssue(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
return mcp.NewTool("update_issue",
- mcp.WithDescription(t("TOOL_UPDATE_ISSUE_DESCRIPTION", "Update an existing issue in a GitHub repository")),
+ mcp.WithDescription(t("TOOL_UPDATE_ISSUE_DESCRIPTION", "Update an existing issue in a GitHub repository.")),
+ mcp.WithToolAnnotation(mcp.ToolAnnotation{
+ Title: t("TOOL_UPDATE_ISSUE_USER_TITLE", "Edit issue"),
+ ReadOnlyHint: ToBoolPtr(false),
+ }),
mcp.WithString("owner",
mcp.Required(),
mcp.Description("Repository owner"),
@@ -509,11 +536,11 @@ func UpdateIssue(getClient GetClientFn, t translations.TranslationHelperFunc) (t
),
),
func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
- owner, err := requiredParam[string](request, "owner")
+ owner, err := RequiredParam[string](request, "owner")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
- repo, err := requiredParam[string](request, "repo")
+ repo, err := RequiredParam[string](request, "repo")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
@@ -607,7 +634,11 @@ func UpdateIssue(getClient GetClientFn, t translations.TranslationHelperFunc) (t
// GetIssueComments creates a tool to get comments for a GitHub issue.
func GetIssueComments(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
return mcp.NewTool("get_issue_comments",
- mcp.WithDescription(t("TOOL_GET_ISSUE_COMMENTS_DESCRIPTION", "Get comments for a GitHub issue")),
+ mcp.WithDescription(t("TOOL_GET_ISSUE_COMMENTS_DESCRIPTION", "Get comments for a specific issue in a GitHub repository.")),
+ mcp.WithToolAnnotation(mcp.ToolAnnotation{
+ Title: t("TOOL_GET_ISSUE_COMMENTS_USER_TITLE", "Get issue comments"),
+ ReadOnlyHint: ToBoolPtr(true),
+ }),
mcp.WithString("owner",
mcp.Required(),
mcp.Description("Repository owner"),
@@ -628,11 +659,11 @@ func GetIssueComments(getClient GetClientFn, t translations.TranslationHelperFun
),
),
func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
- owner, err := requiredParam[string](request, "owner")
+ owner, err := RequiredParam[string](request, "owner")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
- repo, err := requiredParam[string](request, "repo")
+ repo, err := RequiredParam[string](request, "repo")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
@@ -683,6 +714,200 @@ func GetIssueComments(getClient GetClientFn, t translations.TranslationHelperFun
}
}
+// mvpDescription is an MVP idea for generating tool descriptions from structured data in a shared format.
+// It is not intended for widespread usage and is not a complete implementation.
+type mvpDescription struct {
+ summary string
+ outcomes []string
+ referenceLinks []string
+}
+
+func (d *mvpDescription) String() string {
+ var sb strings.Builder
+ sb.WriteString(d.summary)
+ if len(d.outcomes) > 0 {
+ sb.WriteString("\n\n")
+ sb.WriteString("This tool can help with the following outcomes:\n")
+ for _, outcome := range d.outcomes {
+ sb.WriteString(fmt.Sprintf("- %s\n", outcome))
+ }
+ }
+
+ if len(d.referenceLinks) > 0 {
+ sb.WriteString("\n\n")
+ sb.WriteString("More information can be found at:\n")
+ for _, link := range d.referenceLinks {
+ sb.WriteString(fmt.Sprintf("- %s\n", link))
+ }
+ }
+
+ return sb.String()
+}
+
+func AssignCopilotToIssue(getGQLClient GetGQLClientFn, t translations.TranslationHelperFunc) (mcp.Tool, server.ToolHandlerFunc) {
+ description := mvpDescription{
+ summary: "Assign Copilot to a specific issue in a GitHub repository.",
+ outcomes: []string{
+ "a Pull Request created with source code changes to resolve the issue",
+ },
+ referenceLinks: []string{
+ "https://docs.github.com/en/copilot/using-github-copilot/using-copilot-coding-agent-to-work-on-tasks/about-assigning-tasks-to-copilot",
+ },
+ }
+
+ return mcp.NewTool("assign_copilot_to_issue",
+ mcp.WithDescription(t("TOOL_ASSIGN_COPILOT_TO_ISSUE_DESCRIPTION", description.String())),
+ mcp.WithToolAnnotation(mcp.ToolAnnotation{
+ Title: t("TOOL_ASSIGN_COPILOT_TO_ISSUE_USER_TITLE", "Assign Copilot to issue"),
+ ReadOnlyHint: ToBoolPtr(false),
+ IdempotentHint: ToBoolPtr(true),
+ }),
+ mcp.WithString("owner",
+ mcp.Required(),
+ mcp.Description("Repository owner"),
+ ),
+ mcp.WithString("repo",
+ mcp.Required(),
+ mcp.Description("Repository name"),
+ ),
+ mcp.WithNumber("issueNumber",
+ mcp.Required(),
+ mcp.Description("Issue number"),
+ ),
+ ),
+ func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
+ var params struct {
+ Owner string
+ Repo string
+ IssueNumber 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 nil, fmt.Errorf("failed to get GitHub client: %w", err)
+ }
+
+ // Firstly, we try to find the copilot bot in the suggested actors for the repository.
+ // Although as I write this, we would expect copilot to be at the top of the list, in future, maybe
+ // it will not be on the first page of responses, thus we will keep paginating until we find it.
+ type botAssignee struct {
+ ID githubv4.ID
+ Login string
+ TypeName string `graphql:"__typename"`
+ }
+
+ type suggestedActorsQuery struct {
+ Repository struct {
+ SuggestedActors struct {
+ Nodes []struct {
+ Bot botAssignee `graphql:"... on Bot"`
+ }
+ PageInfo struct {
+ HasNextPage bool
+ EndCursor string
+ }
+ } `graphql:"suggestedActors(first: 100, after: $endCursor, capabilities: CAN_BE_ASSIGNED)"`
+ } `graphql:"repository(owner: $owner, name: $name)"`
+ }
+
+ variables := map[string]any{
+ "owner": githubv4.String(params.Owner),
+ "name": githubv4.String(params.Repo),
+ "endCursor": (*githubv4.String)(nil),
+ }
+
+ var copilotAssignee *botAssignee
+ for {
+ var query suggestedActorsQuery
+ err := client.Query(ctx, &query, variables)
+ if err != nil {
+ return nil, err
+ }
+
+ // Iterate all the returned nodes looking for the copilot bot, which is supposed to have the
+ // same name on each host. We need this in order to get the ID for later assignment.
+ for _, node := range query.Repository.SuggestedActors.Nodes {
+ if node.Bot.Login == "copilot-swe-agent" {
+ copilotAssignee = &node.Bot
+ break
+ }
+ }
+
+ if !query.Repository.SuggestedActors.PageInfo.HasNextPage {
+ break
+ }
+ variables["endCursor"] = githubv4.String(query.Repository.SuggestedActors.PageInfo.EndCursor)
+ }
+
+ // If we didn't find the copilot bot, we can't proceed any further.
+ if copilotAssignee == nil {
+ // The e2e tests depend upon this specific message to skip the test.
+ return mcp.NewToolResultError("copilot isn't available as an assignee for this issue. Please inform the user to visit https://docs.github.com/en/copilot/using-github-copilot/using-copilot-coding-agent-to-work-on-tasks/about-assigning-tasks-to-copilot for more information."), nil
+ }
+
+ // Next let's get the GQL Node ID and current assignees for this issue because the only way to
+ // assign copilot is to use replaceActorsForAssignable which requires the full list.
+ var getIssueQuery struct {
+ Repository struct {
+ Issue struct {
+ ID githubv4.ID
+ Assignees struct {
+ Nodes []struct {
+ ID githubv4.ID
+ }
+ } `graphql:"assignees(first: 100)"`
+ } `graphql:"issue(number: $number)"`
+ } `graphql:"repository(owner: $owner, name: $name)"`
+ }
+
+ variables = map[string]any{
+ "owner": githubv4.String(params.Owner),
+ "name": githubv4.String(params.Repo),
+ "number": githubv4.Int(params.IssueNumber),
+ }
+
+ if err := client.Query(ctx, &getIssueQuery, variables); err != nil {
+ return mcp.NewToolResultError(fmt.Sprintf("failed to get issue ID: %v", err)), nil
+ }
+
+ // Finally, do the assignment. Just for reference, assigning copilot to an issue that it is already
+ // assigned to seems to have no impact (which is a good thing).
+ var assignCopilotMutation struct {
+ ReplaceActorsForAssignable struct {
+ Typename string `graphql:"__typename"` // Not required but we need a selector or GQL errors
+ } `graphql:"replaceActorsForAssignable(input: $input)"`
+ }
+
+ actorIDs := make([]githubv4.ID, len(getIssueQuery.Repository.Issue.Assignees.Nodes)+1)
+ for i, node := range getIssueQuery.Repository.Issue.Assignees.Nodes {
+ actorIDs[i] = node.ID
+ }
+ actorIDs[len(getIssueQuery.Repository.Issue.Assignees.Nodes)] = copilotAssignee.ID
+
+ if err := client.Mutate(
+ ctx,
+ &assignCopilotMutation,
+ ReplaceActorsForAssignableInput{
+ AssignableID: getIssueQuery.Repository.Issue.ID,
+ ActorIDs: actorIDs,
+ },
+ nil,
+ ); err != nil {
+ return nil, fmt.Errorf("failed to replace actors for assignable: %w", err)
+ }
+
+ return mcp.NewToolResultText("successfully assigned copilot to issue"), nil
+ }
+}
+
+type ReplaceActorsForAssignableInput struct {
+ AssignableID githubv4.ID `json:"assignableId"`
+ ActorIDs []githubv4.ID `json:"actorIds"`
+}
+
// parseISOTimestamp parses an ISO 8601 timestamp string into a time.Time object.
// Returns the parsed time or an error if parsing fails.
// Example formats supported: "2023-01-15T14:30:00Z", "2023-01-15"
diff --git a/pkg/github/issues_test.go b/pkg/github/issues_test.go
index 61ca0ae7a..251fc32bf 100644
--- a/pkg/github/issues_test.go
+++ b/pkg/github/issues_test.go
@@ -3,14 +3,16 @@ package github
import (
"context"
"encoding/json"
+ "fmt"
"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/v69/github"
- "github.com/mark3labs/mcp-go/mcp"
+ "github.com/google/go-github/v72/github"
"github.com/migueleliasweb/go-github-mock/src/mock"
+ "github.com/shurcooL/githubv4"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -188,17 +190,7 @@ func Test_AddIssueComment(t *testing.T) {
_, handler := AddIssueComment(stubGetClientFn(client), translations.NullTranslationHelper)
// Create call request
- request := mcp.CallToolRequest{
- Params: struct {
- Name string `json:"name"`
- Arguments map[string]interface{} `json:"arguments,omitempty"`
- Meta *struct {
- ProgressToken mcp.ProgressToken `json:"progressToken,omitempty"`
- } `json:"_meta,omitempty"`
- }{
- Arguments: tc.requestArgs,
- },
- }
+ request := createMCPRequest(tc.requestArgs)
// Call handler
result, err := handler(context.Background(), request)
@@ -1130,3 +1122,422 @@ func Test_GetIssueComments(t *testing.T) {
})
}
}
+
+func TestAssignCopilotToIssue(t *testing.T) {
+ t.Parallel()
+
+ // Verify tool definition
+ mockClient := githubv4.NewClient(nil)
+ tool, _ := AssignCopilotToIssue(stubGetGQLClientFn(mockClient), translations.NullTranslationHelper)
+
+ assert.Equal(t, "assign_copilot_to_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, "issueNumber")
+ assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "issueNumber"})
+
+ var pageOfFakeBots = func(n int) []struct{} {
+ // We don't _really_ need real bots here, just objects that count as entries for the page
+ bots := make([]struct{}, n)
+ for i := range n {
+ bots[i] = struct{}{}
+ }
+ return bots
+ }
+
+ tests := []struct {
+ name string
+ requestArgs map[string]any
+ mockedClient *http.Client
+ expectToolError bool
+ expectedToolErrMsg string
+ }{
+ {
+ name: "successful assignment when there are no existing assignees",
+ requestArgs: map[string]any{
+ "owner": "owner",
+ "repo": "repo",
+ "issueNumber": float64(123),
+ },
+ mockedClient: githubv4mock.NewMockedHTTPClient(
+ githubv4mock.NewQueryMatcher(
+ struct {
+ Repository struct {
+ SuggestedActors struct {
+ Nodes []struct {
+ Bot struct {
+ ID githubv4.ID
+ Login githubv4.String
+ TypeName string `graphql:"__typename"`
+ } `graphql:"... on Bot"`
+ }
+ PageInfo struct {
+ HasNextPage bool
+ EndCursor string
+ }
+ } `graphql:"suggestedActors(first: 100, after: $endCursor, capabilities: CAN_BE_ASSIGNED)"`
+ } `graphql:"repository(owner: $owner, name: $name)"`
+ }{},
+ map[string]any{
+ "owner": githubv4.String("owner"),
+ "name": githubv4.String("repo"),
+ "endCursor": (*githubv4.String)(nil),
+ },
+ githubv4mock.DataResponse(map[string]any{
+ "repository": map[string]any{
+ "suggestedActors": map[string]any{
+ "nodes": []any{
+ map[string]any{
+ "id": githubv4.ID("copilot-swe-agent-id"),
+ "login": githubv4.String("copilot-swe-agent"),
+ "__typename": "Bot",
+ },
+ },
+ },
+ },
+ }),
+ ),
+ githubv4mock.NewQueryMatcher(
+ struct {
+ Repository struct {
+ Issue struct {
+ ID githubv4.ID
+ Assignees struct {
+ Nodes []struct {
+ ID githubv4.ID
+ }
+ } `graphql:"assignees(first: 100)"`
+ } `graphql:"issue(number: $number)"`
+ } `graphql:"repository(owner: $owner, name: $name)"`
+ }{},
+ map[string]any{
+ "owner": githubv4.String("owner"),
+ "name": githubv4.String("repo"),
+ "number": githubv4.Int(123),
+ },
+ githubv4mock.DataResponse(map[string]any{
+ "repository": map[string]any{
+ "issue": map[string]any{
+ "id": githubv4.ID("test-issue-id"),
+ "assignees": map[string]any{
+ "nodes": []any{},
+ },
+ },
+ },
+ }),
+ ),
+ githubv4mock.NewMutationMatcher(
+ struct {
+ ReplaceActorsForAssignable struct {
+ Typename string `graphql:"__typename"`
+ } `graphql:"replaceActorsForAssignable(input: $input)"`
+ }{},
+ ReplaceActorsForAssignableInput{
+ AssignableID: githubv4.ID("test-issue-id"),
+ ActorIDs: []githubv4.ID{githubv4.ID("copilot-swe-agent-id")},
+ },
+ nil,
+ githubv4mock.DataResponse(map[string]any{}),
+ ),
+ ),
+ },
+ {
+ name: "successful assignment when there are existing assignees",
+ requestArgs: map[string]any{
+ "owner": "owner",
+ "repo": "repo",
+ "issueNumber": float64(123),
+ },
+ mockedClient: githubv4mock.NewMockedHTTPClient(
+ githubv4mock.NewQueryMatcher(
+ struct {
+ Repository struct {
+ SuggestedActors struct {
+ Nodes []struct {
+ Bot struct {
+ ID githubv4.ID
+ Login githubv4.String
+ TypeName string `graphql:"__typename"`
+ } `graphql:"... on Bot"`
+ }
+ PageInfo struct {
+ HasNextPage bool
+ EndCursor string
+ }
+ } `graphql:"suggestedActors(first: 100, after: $endCursor, capabilities: CAN_BE_ASSIGNED)"`
+ } `graphql:"repository(owner: $owner, name: $name)"`
+ }{},
+ map[string]any{
+ "owner": githubv4.String("owner"),
+ "name": githubv4.String("repo"),
+ "endCursor": (*githubv4.String)(nil),
+ },
+ githubv4mock.DataResponse(map[string]any{
+ "repository": map[string]any{
+ "suggestedActors": map[string]any{
+ "nodes": []any{
+ map[string]any{
+ "id": githubv4.ID("copilot-swe-agent-id"),
+ "login": githubv4.String("copilot-swe-agent"),
+ "__typename": "Bot",
+ },
+ },
+ },
+ },
+ }),
+ ),
+ githubv4mock.NewQueryMatcher(
+ struct {
+ Repository struct {
+ Issue struct {
+ ID githubv4.ID
+ Assignees struct {
+ Nodes []struct {
+ ID githubv4.ID
+ }
+ } `graphql:"assignees(first: 100)"`
+ } `graphql:"issue(number: $number)"`
+ } `graphql:"repository(owner: $owner, name: $name)"`
+ }{},
+ map[string]any{
+ "owner": githubv4.String("owner"),
+ "name": githubv4.String("repo"),
+ "number": githubv4.Int(123),
+ },
+ githubv4mock.DataResponse(map[string]any{
+ "repository": map[string]any{
+ "issue": map[string]any{
+ "id": githubv4.ID("test-issue-id"),
+ "assignees": map[string]any{
+ "nodes": []any{
+ map[string]any{
+ "id": githubv4.ID("existing-assignee-id"),
+ },
+ map[string]any{
+ "id": githubv4.ID("existing-assignee-id-2"),
+ },
+ },
+ },
+ },
+ },
+ }),
+ ),
+ githubv4mock.NewMutationMatcher(
+ struct {
+ ReplaceActorsForAssignable struct {
+ Typename string `graphql:"__typename"`
+ } `graphql:"replaceActorsForAssignable(input: $input)"`
+ }{},
+ ReplaceActorsForAssignableInput{
+ AssignableID: githubv4.ID("test-issue-id"),
+ ActorIDs: []githubv4.ID{
+ githubv4.ID("existing-assignee-id"),
+ githubv4.ID("existing-assignee-id-2"),
+ githubv4.ID("copilot-swe-agent-id"),
+ },
+ },
+ nil,
+ githubv4mock.DataResponse(map[string]any{}),
+ ),
+ ),
+ },
+ {
+ name: "copilot bot not on first page of suggested actors",
+ requestArgs: map[string]any{
+ "owner": "owner",
+ "repo": "repo",
+ "issueNumber": float64(123),
+ },
+ mockedClient: githubv4mock.NewMockedHTTPClient(
+ // First page of suggested actors
+ githubv4mock.NewQueryMatcher(
+ struct {
+ Repository struct {
+ SuggestedActors struct {
+ Nodes []struct {
+ Bot struct {
+ ID githubv4.ID
+ Login githubv4.String
+ TypeName string `graphql:"__typename"`
+ } `graphql:"... on Bot"`
+ }
+ PageInfo struct {
+ HasNextPage bool
+ EndCursor string
+ }
+ } `graphql:"suggestedActors(first: 100, after: $endCursor, capabilities: CAN_BE_ASSIGNED)"`
+ } `graphql:"repository(owner: $owner, name: $name)"`
+ }{},
+ map[string]any{
+ "owner": githubv4.String("owner"),
+ "name": githubv4.String("repo"),
+ "endCursor": (*githubv4.String)(nil),
+ },
+ githubv4mock.DataResponse(map[string]any{
+ "repository": map[string]any{
+ "suggestedActors": map[string]any{
+ "nodes": pageOfFakeBots(100),
+ "pageInfo": map[string]any{
+ "hasNextPage": true,
+ "endCursor": githubv4.String("next-page-cursor"),
+ },
+ },
+ },
+ }),
+ ),
+ // Second page of suggested actors
+ githubv4mock.NewQueryMatcher(
+ struct {
+ Repository struct {
+ SuggestedActors struct {
+ Nodes []struct {
+ Bot struct {
+ ID githubv4.ID
+ Login githubv4.String
+ TypeName string `graphql:"__typename"`
+ } `graphql:"... on Bot"`
+ }
+ PageInfo struct {
+ HasNextPage bool
+ EndCursor string
+ }
+ } `graphql:"suggestedActors(first: 100, after: $endCursor, capabilities: CAN_BE_ASSIGNED)"`
+ } `graphql:"repository(owner: $owner, name: $name)"`
+ }{},
+ map[string]any{
+ "owner": githubv4.String("owner"),
+ "name": githubv4.String("repo"),
+ "endCursor": githubv4.String("next-page-cursor"),
+ },
+ githubv4mock.DataResponse(map[string]any{
+ "repository": map[string]any{
+ "suggestedActors": map[string]any{
+ "nodes": []any{
+ map[string]any{
+ "id": githubv4.ID("copilot-swe-agent-id"),
+ "login": githubv4.String("copilot-swe-agent"),
+ "__typename": "Bot",
+ },
+ },
+ },
+ },
+ }),
+ ),
+ githubv4mock.NewQueryMatcher(
+ struct {
+ Repository struct {
+ Issue struct {
+ ID githubv4.ID
+ Assignees struct {
+ Nodes []struct {
+ ID githubv4.ID
+ }
+ } `graphql:"assignees(first: 100)"`
+ } `graphql:"issue(number: $number)"`
+ } `graphql:"repository(owner: $owner, name: $name)"`
+ }{},
+ map[string]any{
+ "owner": githubv4.String("owner"),
+ "name": githubv4.String("repo"),
+ "number": githubv4.Int(123),
+ },
+ githubv4mock.DataResponse(map[string]any{
+ "repository": map[string]any{
+ "issue": map[string]any{
+ "id": githubv4.ID("test-issue-id"),
+ "assignees": map[string]any{
+ "nodes": []any{},
+ },
+ },
+ },
+ }),
+ ),
+ githubv4mock.NewMutationMatcher(
+ struct {
+ ReplaceActorsForAssignable struct {
+ Typename string `graphql:"__typename"`
+ } `graphql:"replaceActorsForAssignable(input: $input)"`
+ }{},
+ ReplaceActorsForAssignableInput{
+ AssignableID: githubv4.ID("test-issue-id"),
+ ActorIDs: []githubv4.ID{githubv4.ID("copilot-swe-agent-id")},
+ },
+ nil,
+ githubv4mock.DataResponse(map[string]any{}),
+ ),
+ ),
+ },
+ {
+ name: "copilot not a suggested actor",
+ requestArgs: map[string]any{
+ "owner": "owner",
+ "repo": "repo",
+ "issueNumber": float64(123),
+ },
+ mockedClient: githubv4mock.NewMockedHTTPClient(
+ githubv4mock.NewQueryMatcher(
+ struct {
+ Repository struct {
+ SuggestedActors struct {
+ Nodes []struct {
+ Bot struct {
+ ID githubv4.ID
+ Login githubv4.String
+ TypeName string `graphql:"__typename"`
+ } `graphql:"... on Bot"`
+ }
+ PageInfo struct {
+ HasNextPage bool
+ EndCursor string
+ }
+ } `graphql:"suggestedActors(first: 100, after: $endCursor, capabilities: CAN_BE_ASSIGNED)"`
+ } `graphql:"repository(owner: $owner, name: $name)"`
+ }{},
+ map[string]any{
+ "owner": githubv4.String("owner"),
+ "name": githubv4.String("repo"),
+ "endCursor": (*githubv4.String)(nil),
+ },
+ githubv4mock.DataResponse(map[string]any{
+ "repository": map[string]any{
+ "suggestedActors": map[string]any{
+ "nodes": []any{},
+ },
+ },
+ }),
+ ),
+ ),
+ expectToolError: true,
+ expectedToolErrMsg: "copilot isn't available as an assignee for this issue. Please inform the user to visit https://docs.github.com/en/copilot/using-github-copilot/using-copilot-coding-agent-to-work-on-tasks/about-assigning-tasks-to-copilot for more information.",
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+
+ t.Parallel()
+ // Setup client with mock
+ client := githubv4.NewClient(tc.mockedClient)
+ _, handler := AssignCopilotToIssue(stubGetGQLClientFn(client), translations.NullTranslationHelper)
+
+ // Create call request
+ request := createMCPRequest(tc.requestArgs)
+
+ // Call handler
+ result, err := handler(context.Background(), request)
+ require.NoError(t, err)
+
+ textContent := getTextResult(t, result)
+
+ if tc.expectToolError {
+ require.True(t, result.IsError)
+ assert.Contains(t, textContent.Text, tc.expectedToolErrMsg)
+ return
+ }
+
+ require.False(t, result.IsError, fmt.Sprintf("expected there to be no tool error, text was %s", textContent.Text))
+ require.Equal(t, textContent.Text, "successfully assigned copilot to issue")
+ })
+ }
+}
diff --git a/pkg/github/notifications.go b/pkg/github/notifications.go
new file mode 100644
index 000000000..677ee99f0
--- /dev/null
+++ b/pkg/github/notifications.go
@@ -0,0 +1,500 @@
+package github
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "strconv"
+ "time"
+
+ "github.com/github/github-mcp-server/pkg/translations"
+ "github.com/google/go-github/v72/github"
+ "github.com/mark3labs/mcp-go/mcp"
+ "github.com/mark3labs/mcp-go/server"
+)
+
+const (
+ FilterDefault = "default"
+ FilterIncludeRead = "include_read_notifications"
+ FilterOnlyParticipating = "only_participating"
+)
+
+// ListNotifications creates a tool to list notifications for the current user.
+func ListNotifications(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
+ return mcp.NewTool("list_notifications",
+ mcp.WithDescription(t("TOOL_LIST_NOTIFICATIONS_DESCRIPTION", "Lists all GitHub notifications for the authenticated user, including unread notifications, mentions, review requests, assignments, and updates on issues or pull requests. Use this tool whenever the user asks what to work on next, requests a summary of their GitHub activity, wants to see pending reviews, or needs to check for new updates or tasks. This tool is the primary way to discover actionable items, reminders, and outstanding work on GitHub. Always call this tool when asked what to work on next, what is pending, or what needs attention in GitHub.")),
+ mcp.WithToolAnnotation(mcp.ToolAnnotation{
+ Title: t("TOOL_LIST_NOTIFICATIONS_USER_TITLE", "List notifications"),
+ ReadOnlyHint: ToBoolPtr(true),
+ }),
+ mcp.WithString("filter",
+ mcp.Description("Filter notifications to, use default unless specified. Read notifications are ones that have already been acknowledged by the user. Participating notifications are those that the user is directly involved in, such as issues or pull requests they have commented on or created."),
+ mcp.Enum(FilterDefault, FilterIncludeRead, FilterOnlyParticipating),
+ ),
+ mcp.WithString("since",
+ mcp.Description("Only show notifications updated after the given time (ISO 8601 format)"),
+ ),
+ mcp.WithString("before",
+ mcp.Description("Only show notifications updated before the given time (ISO 8601 format)"),
+ ),
+ mcp.WithString("owner",
+ mcp.Description("Optional repository owner. If provided with repo, only notifications for this repository are listed."),
+ ),
+ mcp.WithString("repo",
+ mcp.Description("Optional repository name. If provided with owner, only notifications for this repository are listed."),
+ ),
+ WithPagination(),
+ ),
+ func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
+ client, err := getClient(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get GitHub client: %w", err)
+ }
+
+ filter, err := OptionalParam[string](request, "filter")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+
+ since, err := OptionalParam[string](request, "since")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+
+ before, err := OptionalParam[string](request, "before")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+
+ owner, err := OptionalParam[string](request, "owner")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+ repo, err := OptionalParam[string](request, "repo")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+
+ paginationParams, err := OptionalPaginationParams(request)
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+
+ // Build options
+ opts := &github.NotificationListOptions{
+ All: filter == FilterIncludeRead,
+ Participating: filter == FilterOnlyParticipating,
+ ListOptions: github.ListOptions{
+ Page: paginationParams.page,
+ PerPage: paginationParams.perPage,
+ },
+ }
+
+ // Parse time parameters if provided
+ if since != "" {
+ sinceTime, err := time.Parse(time.RFC3339, since)
+ if err != nil {
+ return mcp.NewToolResultError(fmt.Sprintf("invalid since time format, should be RFC3339/ISO8601: %v", err)), nil
+ }
+ opts.Since = sinceTime
+ }
+
+ if before != "" {
+ beforeTime, err := time.Parse(time.RFC3339, before)
+ if err != nil {
+ return mcp.NewToolResultError(fmt.Sprintf("invalid before time format, should be RFC3339/ISO8601: %v", err)), nil
+ }
+ opts.Before = beforeTime
+ }
+
+ var notifications []*github.Notification
+ var resp *github.Response
+
+ if owner != "" && repo != "" {
+ notifications, resp, err = client.Activity.ListRepositoryNotifications(ctx, owner, repo, opts)
+ } else {
+ notifications, resp, err = client.Activity.ListNotifications(ctx, opts)
+ }
+ if err != nil {
+ return nil, fmt.Errorf("failed to get notifications: %w", err)
+ }
+ defer func() { _ = resp.Body.Close() }()
+
+ if resp.StatusCode != http.StatusOK {
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, fmt.Errorf("failed to read response body: %w", err)
+ }
+ return mcp.NewToolResultError(fmt.Sprintf("failed to get notifications: %s", string(body))), nil
+ }
+
+ // Marshal response to JSON
+ r, err := json.Marshal(notifications)
+ if err != nil {
+ return nil, fmt.Errorf("failed to marshal response: %w", err)
+ }
+
+ return mcp.NewToolResultText(string(r)), nil
+ }
+}
+
+// DismissNotification creates a tool to mark a notification as read/done.
+func DismissNotification(getclient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
+ return mcp.NewTool("dismiss_notification",
+ mcp.WithDescription(t("TOOL_DISMISS_NOTIFICATION_DESCRIPTION", "Dismiss a notification by marking it as read or done")),
+ mcp.WithToolAnnotation(mcp.ToolAnnotation{
+ Title: t("TOOL_DISMISS_NOTIFICATION_USER_TITLE", "Dismiss notification"),
+ ReadOnlyHint: ToBoolPtr(false),
+ }),
+ mcp.WithString("threadID",
+ mcp.Required(),
+ mcp.Description("The ID of the notification thread"),
+ ),
+ mcp.WithString("state", mcp.Description("The new state of the notification (read/done)"), mcp.Enum("read", "done")),
+ ),
+ func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
+ client, err := getclient(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get GitHub client: %w", err)
+ }
+
+ threadID, err := RequiredParam[string](request, "threadID")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+
+ state, err := RequiredParam[string](request, "state")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+
+ var resp *github.Response
+ switch state {
+ case "done":
+ // for some inexplicable reason, the API seems to have threadID as int64 and string depending on the endpoint
+ var threadIDInt int64
+ threadIDInt, err = strconv.ParseInt(threadID, 10, 64)
+ if err != nil {
+ return mcp.NewToolResultError(fmt.Sprintf("invalid threadID format: %v", err)), nil
+ }
+ resp, err = client.Activity.MarkThreadDone(ctx, threadIDInt)
+ case "read":
+ resp, err = client.Activity.MarkThreadRead(ctx, threadID)
+ default:
+ return mcp.NewToolResultError("Invalid state. Must be one of: read, done."), nil
+ }
+
+ if err != nil {
+ return nil, fmt.Errorf("failed to mark notification as %s: %w", state, err)
+ }
+ defer func() { _ = resp.Body.Close() }()
+
+ if resp.StatusCode != http.StatusResetContent && 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 mark notification as %s: %s", state, string(body))), nil
+ }
+
+ return mcp.NewToolResultText(fmt.Sprintf("Notification marked as %s", state)), nil
+ }
+}
+
+// MarkAllNotificationsRead creates a tool to mark all notifications as read.
+func MarkAllNotificationsRead(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
+ return mcp.NewTool("mark_all_notifications_read",
+ mcp.WithDescription(t("TOOL_MARK_ALL_NOTIFICATIONS_READ_DESCRIPTION", "Mark all notifications as read")),
+ mcp.WithToolAnnotation(mcp.ToolAnnotation{
+ Title: t("TOOL_MARK_ALL_NOTIFICATIONS_READ_USER_TITLE", "Mark all notifications as read"),
+ ReadOnlyHint: ToBoolPtr(false),
+ }),
+ mcp.WithString("lastReadAt",
+ mcp.Description("Describes the last point that notifications were checked (optional). Default: Now"),
+ ),
+ mcp.WithString("owner",
+ mcp.Description("Optional repository owner. If provided with repo, only notifications for this repository are marked as read."),
+ ),
+ mcp.WithString("repo",
+ mcp.Description("Optional repository name. If provided with owner, only notifications for this repository are marked as read."),
+ ),
+ ),
+ func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
+ client, err := getClient(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get GitHub client: %w", err)
+ }
+
+ lastReadAt, err := OptionalParam[string](request, "lastReadAt")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+
+ owner, err := OptionalParam[string](request, "owner")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+ repo, err := OptionalParam[string](request, "repo")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+
+ var lastReadTime time.Time
+ if lastReadAt != "" {
+ lastReadTime, err = time.Parse(time.RFC3339, lastReadAt)
+ if err != nil {
+ return mcp.NewToolResultError(fmt.Sprintf("invalid lastReadAt time format, should be RFC3339/ISO8601: %v", err)), nil
+ }
+ } else {
+ lastReadTime = time.Now()
+ }
+
+ markReadOptions := github.Timestamp{
+ Time: lastReadTime,
+ }
+
+ var resp *github.Response
+ if owner != "" && repo != "" {
+ resp, err = client.Activity.MarkRepositoryNotificationsRead(ctx, owner, repo, markReadOptions)
+ } else {
+ resp, err = client.Activity.MarkNotificationsRead(ctx, markReadOptions)
+ }
+ if err != nil {
+ return nil, fmt.Errorf("failed to mark all notifications as read: %w", err)
+ }
+ defer func() { _ = resp.Body.Close() }()
+
+ if resp.StatusCode != http.StatusResetContent && 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 mark all notifications as read: %s", string(body))), nil
+ }
+
+ return mcp.NewToolResultText("All notifications marked as read"), nil
+ }
+}
+
+// GetNotificationDetails creates a tool to get details for a specific notification.
+func GetNotificationDetails(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
+ return mcp.NewTool("get_notification_details",
+ mcp.WithDescription(t("TOOL_GET_NOTIFICATION_DETAILS_DESCRIPTION", "Get detailed information for a specific GitHub notification, always call this tool when the user asks for details about a specific notification, if you don't know the ID list notifications first.")),
+ mcp.WithToolAnnotation(mcp.ToolAnnotation{
+ Title: t("TOOL_GET_NOTIFICATION_DETAILS_USER_TITLE", "Get notification details"),
+ ReadOnlyHint: ToBoolPtr(true),
+ }),
+ mcp.WithString("notificationID",
+ mcp.Required(),
+ mcp.Description("The ID of the notification"),
+ ),
+ ),
+ func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
+ client, err := getClient(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get GitHub client: %w", err)
+ }
+
+ notificationID, err := RequiredParam[string](request, "notificationID")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+
+ thread, resp, err := client.Activity.GetThread(ctx, notificationID)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get notification details: %w", err)
+ }
+ defer func() { _ = resp.Body.Close() }()
+
+ if resp.StatusCode != http.StatusOK {
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, fmt.Errorf("failed to read response body: %w", err)
+ }
+ return mcp.NewToolResultError(fmt.Sprintf("failed to get notification details: %s", string(body))), nil
+ }
+
+ r, err := json.Marshal(thread)
+ if err != nil {
+ return nil, fmt.Errorf("failed to marshal response: %w", err)
+ }
+
+ return mcp.NewToolResultText(string(r)), nil
+ }
+}
+
+// Enum values for ManageNotificationSubscription action
+const (
+ NotificationActionIgnore = "ignore"
+ NotificationActionWatch = "watch"
+ NotificationActionDelete = "delete"
+)
+
+// ManageNotificationSubscription creates a tool to manage a notification subscription (ignore, watch, delete)
+func ManageNotificationSubscription(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
+ return mcp.NewTool("manage_notification_subscription",
+ mcp.WithDescription(t("TOOL_MANAGE_NOTIFICATION_SUBSCRIPTION_DESCRIPTION", "Manage a notification subscription: ignore, watch, or delete a notification thread subscription.")),
+ mcp.WithToolAnnotation(mcp.ToolAnnotation{
+ Title: t("TOOL_MANAGE_NOTIFICATION_SUBSCRIPTION_USER_TITLE", "Manage notification subscription"),
+ ReadOnlyHint: ToBoolPtr(false),
+ }),
+ mcp.WithString("notificationID",
+ mcp.Required(),
+ mcp.Description("The ID of the notification thread."),
+ ),
+ mcp.WithString("action",
+ mcp.Required(),
+ mcp.Description("Action to perform: ignore, watch, or delete the notification subscription."),
+ mcp.Enum(NotificationActionIgnore, NotificationActionWatch, NotificationActionDelete),
+ ),
+ ),
+ func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
+ client, err := getClient(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get GitHub client: %w", err)
+ }
+
+ notificationID, err := RequiredParam[string](request, "notificationID")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+ action, err := RequiredParam[string](request, "action")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+
+ var (
+ resp *github.Response
+ result any
+ apiErr error
+ )
+
+ switch action {
+ case NotificationActionIgnore:
+ sub := &github.Subscription{Ignored: ToBoolPtr(true)}
+ result, resp, apiErr = client.Activity.SetThreadSubscription(ctx, notificationID, sub)
+ case NotificationActionWatch:
+ sub := &github.Subscription{Ignored: ToBoolPtr(false), Subscribed: ToBoolPtr(true)}
+ result, resp, apiErr = client.Activity.SetThreadSubscription(ctx, notificationID, sub)
+ case NotificationActionDelete:
+ resp, apiErr = client.Activity.DeleteThreadSubscription(ctx, notificationID)
+ default:
+ return mcp.NewToolResultError("Invalid action. Must be one of: ignore, watch, delete."), nil
+ }
+
+ if apiErr != nil {
+ return nil, fmt.Errorf("failed to %s notification subscription: %w", action, apiErr)
+ }
+ defer func() { _ = resp.Body.Close() }()
+
+ if resp.StatusCode < 200 || resp.StatusCode >= 300 {
+ body, _ := io.ReadAll(resp.Body)
+ return mcp.NewToolResultError(fmt.Sprintf("failed to %s notification subscription: %s", action, string(body))), nil
+ }
+
+ if action == NotificationActionDelete {
+ // Special case for delete as there is no response body
+ return mcp.NewToolResultText("Notification subscription deleted"), nil
+ }
+
+ r, err := json.Marshal(result)
+ if err != nil {
+ return nil, fmt.Errorf("failed to marshal response: %w", err)
+ }
+ return mcp.NewToolResultText(string(r)), nil
+ }
+}
+
+const (
+ RepositorySubscriptionActionWatch = "watch"
+ RepositorySubscriptionActionIgnore = "ignore"
+ RepositorySubscriptionActionDelete = "delete"
+)
+
+// ManageRepositoryNotificationSubscription creates a tool to manage a repository notification subscription (ignore, watch, delete)
+func ManageRepositoryNotificationSubscription(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
+ return mcp.NewTool("manage_repository_notification_subscription",
+ mcp.WithDescription(t("TOOL_MANAGE_REPOSITORY_NOTIFICATION_SUBSCRIPTION_DESCRIPTION", "Manage a repository notification subscription: ignore, watch, or delete repository notifications subscription for the provided repository.")),
+ mcp.WithToolAnnotation(mcp.ToolAnnotation{
+ Title: t("TOOL_MANAGE_REPOSITORY_NOTIFICATION_SUBSCRIPTION_USER_TITLE", "Manage repository notification subscription"),
+ ReadOnlyHint: ToBoolPtr(false),
+ }),
+ mcp.WithString("owner",
+ mcp.Required(),
+ mcp.Description("The account owner of the repository."),
+ ),
+ mcp.WithString("repo",
+ mcp.Required(),
+ mcp.Description("The name of the repository."),
+ ),
+ mcp.WithString("action",
+ mcp.Required(),
+ mcp.Description("Action to perform: ignore, watch, or delete the repository notification subscription."),
+ mcp.Enum(RepositorySubscriptionActionIgnore, RepositorySubscriptionActionWatch, RepositorySubscriptionActionDelete),
+ ),
+ ),
+ func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
+ client, err := getClient(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get GitHub client: %w", err)
+ }
+
+ 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
+ }
+ action, err := RequiredParam[string](request, "action")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+
+ var (
+ resp *github.Response
+ result any
+ apiErr error
+ )
+
+ switch action {
+ case RepositorySubscriptionActionIgnore:
+ sub := &github.Subscription{Ignored: ToBoolPtr(true)}
+ result, resp, apiErr = client.Activity.SetRepositorySubscription(ctx, owner, repo, sub)
+ case RepositorySubscriptionActionWatch:
+ sub := &github.Subscription{Ignored: ToBoolPtr(false), Subscribed: ToBoolPtr(true)}
+ result, resp, apiErr = client.Activity.SetRepositorySubscription(ctx, owner, repo, sub)
+ case RepositorySubscriptionActionDelete:
+ resp, apiErr = client.Activity.DeleteRepositorySubscription(ctx, owner, repo)
+ default:
+ return mcp.NewToolResultError("Invalid action. Must be one of: ignore, watch, delete."), nil
+ }
+
+ if apiErr != nil {
+ return nil, fmt.Errorf("failed to %s repository subscription: %w", action, apiErr)
+ }
+ if resp != nil {
+ defer func() { _ = resp.Body.Close() }()
+ }
+
+ // Handle non-2xx status codes
+ if resp != nil && (resp.StatusCode < 200 || resp.StatusCode >= 300) {
+ body, _ := io.ReadAll(resp.Body)
+ return mcp.NewToolResultError(fmt.Sprintf("failed to %s repository subscription: %s", action, string(body))), nil
+ }
+
+ if action == RepositorySubscriptionActionDelete {
+ // Special case for delete as there is no response body
+ return mcp.NewToolResultText("Repository subscription deleted"), nil
+ }
+
+ r, err := json.Marshal(result)
+ if err != nil {
+ return nil, fmt.Errorf("failed to marshal response: %w", err)
+ }
+ return mcp.NewToolResultText(string(r)), nil
+ }
+}
diff --git a/pkg/github/notifications_test.go b/pkg/github/notifications_test.go
new file mode 100644
index 000000000..173f1a787
--- /dev/null
+++ b/pkg/github/notifications_test.go
@@ -0,0 +1,743 @@
+package github
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "testing"
+
+ "github.com/github/github-mcp-server/pkg/translations"
+ "github.com/google/go-github/v72/github"
+ "github.com/migueleliasweb/go-github-mock/src/mock"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func Test_ListNotifications(t *testing.T) {
+ // Verify tool definition and schema
+ mockClient := github.NewClient(nil)
+ tool, _ := ListNotifications(stubGetClientFn(mockClient), translations.NullTranslationHelper)
+ assert.Equal(t, "list_notifications", tool.Name)
+ assert.NotEmpty(t, tool.Description)
+ assert.Contains(t, tool.InputSchema.Properties, "filter")
+ assert.Contains(t, tool.InputSchema.Properties, "since")
+ assert.Contains(t, tool.InputSchema.Properties, "before")
+ assert.Contains(t, tool.InputSchema.Properties, "owner")
+ assert.Contains(t, tool.InputSchema.Properties, "repo")
+ assert.Contains(t, tool.InputSchema.Properties, "page")
+ assert.Contains(t, tool.InputSchema.Properties, "perPage")
+ // All fields are optional, so Required should be empty
+ assert.Empty(t, tool.InputSchema.Required)
+
+ mockNotification := &github.Notification{
+ ID: github.Ptr("123"),
+ Reason: github.Ptr("mention"),
+ }
+
+ tests := []struct {
+ name string
+ mockedClient *http.Client
+ requestArgs map[string]interface{}
+ expectError bool
+ expectedResult []*github.Notification
+ expectedErrMsg string
+ }{
+ {
+ name: "success default filter (no params)",
+ mockedClient: mock.NewMockedHTTPClient(
+ mock.WithRequestMatch(
+ mock.GetNotifications,
+ []*github.Notification{mockNotification},
+ ),
+ ),
+ requestArgs: map[string]interface{}{},
+ expectError: false,
+ expectedResult: []*github.Notification{mockNotification},
+ },
+ {
+ name: "success with filter=include_read_notifications",
+ mockedClient: mock.NewMockedHTTPClient(
+ mock.WithRequestMatch(
+ mock.GetNotifications,
+ []*github.Notification{mockNotification},
+ ),
+ ),
+ requestArgs: map[string]interface{}{
+ "filter": "include_read_notifications",
+ },
+ expectError: false,
+ expectedResult: []*github.Notification{mockNotification},
+ },
+ {
+ name: "success with filter=only_participating",
+ mockedClient: mock.NewMockedHTTPClient(
+ mock.WithRequestMatch(
+ mock.GetNotifications,
+ []*github.Notification{mockNotification},
+ ),
+ ),
+ requestArgs: map[string]interface{}{
+ "filter": "only_participating",
+ },
+ expectError: false,
+ expectedResult: []*github.Notification{mockNotification},
+ },
+ {
+ name: "success for repo notifications",
+ mockedClient: mock.NewMockedHTTPClient(
+ mock.WithRequestMatch(
+ mock.GetReposNotificationsByOwnerByRepo,
+ []*github.Notification{mockNotification},
+ ),
+ ),
+ requestArgs: map[string]interface{}{
+ "filter": "default",
+ "since": "2024-01-01T00:00:00Z",
+ "before": "2024-01-02T00:00:00Z",
+ "owner": "octocat",
+ "repo": "hello-world",
+ "page": float64(2),
+ "perPage": float64(10),
+ },
+ expectError: false,
+ expectedResult: []*github.Notification{mockNotification},
+ },
+ {
+ name: "error",
+ mockedClient: mock.NewMockedHTTPClient(
+ mock.WithRequestMatchHandler(
+ mock.GetNotifications,
+ mockResponse(t, http.StatusInternalServerError, `{"message": "error"}`),
+ ),
+ ),
+ requestArgs: map[string]interface{}{},
+ expectError: true,
+ expectedErrMsg: "error",
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ client := github.NewClient(tc.mockedClient)
+ _, handler := ListNotifications(stubGetClientFn(client), translations.NullTranslationHelper)
+ request := createMCPRequest(tc.requestArgs)
+ result, err := handler(context.Background(), request)
+
+ if tc.expectError {
+ require.Error(t, err)
+ if tc.expectedErrMsg != "" {
+ assert.Contains(t, err.Error(), tc.expectedErrMsg)
+ }
+ return
+ }
+
+ require.NoError(t, err)
+ textContent := getTextResult(t, result)
+ t.Logf("textContent: %s", textContent.Text)
+ var returned []*github.Notification
+ err = json.Unmarshal([]byte(textContent.Text), &returned)
+ require.NoError(t, err)
+ require.NotEmpty(t, returned)
+ assert.Equal(t, *tc.expectedResult[0].ID, *returned[0].ID)
+ })
+ }
+}
+
+func Test_ManageNotificationSubscription(t *testing.T) {
+ // Verify tool definition and schema
+ mockClient := github.NewClient(nil)
+ tool, _ := ManageNotificationSubscription(stubGetClientFn(mockClient), translations.NullTranslationHelper)
+ assert.Equal(t, "manage_notification_subscription", tool.Name)
+ assert.NotEmpty(t, tool.Description)
+ assert.Contains(t, tool.InputSchema.Properties, "notificationID")
+ assert.Contains(t, tool.InputSchema.Properties, "action")
+ assert.ElementsMatch(t, tool.InputSchema.Required, []string{"notificationID", "action"})
+
+ mockSub := &github.Subscription{Ignored: github.Ptr(true)}
+ mockSubWatch := &github.Subscription{Ignored: github.Ptr(false), Subscribed: github.Ptr(true)}
+
+ tests := []struct {
+ name string
+ mockedClient *http.Client
+ requestArgs map[string]interface{}
+ expectError bool
+ expectIgnored *bool
+ expectDeleted bool
+ expectInvalid bool
+ expectedErrMsg string
+ }{
+ {
+ name: "ignore subscription",
+ mockedClient: mock.NewMockedHTTPClient(
+ mock.WithRequestMatch(
+ mock.PutNotificationsThreadsSubscriptionByThreadId,
+ mockSub,
+ ),
+ ),
+ requestArgs: map[string]interface{}{
+ "notificationID": "123",
+ "action": "ignore",
+ },
+ expectError: false,
+ expectIgnored: github.Ptr(true),
+ },
+ {
+ name: "watch subscription",
+ mockedClient: mock.NewMockedHTTPClient(
+ mock.WithRequestMatch(
+ mock.PutNotificationsThreadsSubscriptionByThreadId,
+ mockSubWatch,
+ ),
+ ),
+ requestArgs: map[string]interface{}{
+ "notificationID": "123",
+ "action": "watch",
+ },
+ expectError: false,
+ expectIgnored: github.Ptr(false),
+ },
+ {
+ name: "delete subscription",
+ mockedClient: mock.NewMockedHTTPClient(
+ mock.WithRequestMatch(
+ mock.DeleteNotificationsThreadsSubscriptionByThreadId,
+ nil,
+ ),
+ ),
+ requestArgs: map[string]interface{}{
+ "notificationID": "123",
+ "action": "delete",
+ },
+ expectError: false,
+ expectDeleted: true,
+ },
+ {
+ name: "invalid action",
+ mockedClient: mock.NewMockedHTTPClient(),
+ requestArgs: map[string]interface{}{
+ "notificationID": "123",
+ "action": "invalid",
+ },
+ expectError: false,
+ expectInvalid: true,
+ },
+ {
+ name: "missing required notificationID",
+ mockedClient: mock.NewMockedHTTPClient(),
+ requestArgs: map[string]interface{}{
+ "action": "ignore",
+ },
+ expectError: true,
+ },
+ {
+ name: "missing required action",
+ mockedClient: mock.NewMockedHTTPClient(),
+ requestArgs: map[string]interface{}{
+ "notificationID": "123",
+ },
+ expectError: true,
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ client := github.NewClient(tc.mockedClient)
+ _, handler := ManageNotificationSubscription(stubGetClientFn(client), translations.NullTranslationHelper)
+ request := createMCPRequest(tc.requestArgs)
+ result, err := handler(context.Background(), request)
+
+ if tc.expectError {
+ require.NoError(t, err)
+ require.NotNil(t, result)
+ text := getTextResult(t, result).Text
+ switch {
+ case tc.requestArgs["notificationID"] == nil:
+ assert.Contains(t, text, "missing required parameter: notificationID")
+ case tc.requestArgs["action"] == nil:
+ assert.Contains(t, text, "missing required parameter: action")
+ default:
+ assert.Contains(t, text, "error")
+ }
+ return
+ }
+
+ require.NoError(t, err)
+ textContent := getTextResult(t, result)
+ if tc.expectIgnored != nil {
+ var returned github.Subscription
+ err = json.Unmarshal([]byte(textContent.Text), &returned)
+ require.NoError(t, err)
+ assert.Equal(t, *tc.expectIgnored, *returned.Ignored)
+ }
+ if tc.expectDeleted {
+ assert.Contains(t, textContent.Text, "deleted")
+ }
+ if tc.expectInvalid {
+ assert.Contains(t, textContent.Text, "Invalid action")
+ }
+ })
+ }
+}
+
+func Test_ManageRepositoryNotificationSubscription(t *testing.T) {
+ // Verify tool definition and schema
+ mockClient := github.NewClient(nil)
+ tool, _ := ManageRepositoryNotificationSubscription(stubGetClientFn(mockClient), translations.NullTranslationHelper)
+ assert.Equal(t, "manage_repository_notification_subscription", 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, "action")
+ assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "action"})
+
+ mockSub := &github.Subscription{Ignored: github.Ptr(true)}
+ mockWatchSub := &github.Subscription{Ignored: github.Ptr(false), Subscribed: github.Ptr(true)}
+
+ tests := []struct {
+ name string
+ mockedClient *http.Client
+ requestArgs map[string]interface{}
+ expectError bool
+ expectIgnored *bool
+ expectSubscribed *bool
+ expectDeleted bool
+ expectInvalid bool
+ expectedErrMsg string
+ }{
+ {
+ name: "ignore subscription",
+ mockedClient: mock.NewMockedHTTPClient(
+ mock.WithRequestMatch(
+ mock.PutReposSubscriptionByOwnerByRepo,
+ mockSub,
+ ),
+ ),
+ requestArgs: map[string]interface{}{
+ "owner": "owner",
+ "repo": "repo",
+ "action": "ignore",
+ },
+ expectError: false,
+ expectIgnored: github.Ptr(true),
+ },
+ {
+ name: "watch subscription",
+ mockedClient: mock.NewMockedHTTPClient(
+ mock.WithRequestMatch(
+ mock.PutReposSubscriptionByOwnerByRepo,
+ mockWatchSub,
+ ),
+ ),
+ requestArgs: map[string]interface{}{
+ "owner": "owner",
+ "repo": "repo",
+ "action": "watch",
+ },
+ expectError: false,
+ expectIgnored: github.Ptr(false),
+ expectSubscribed: github.Ptr(true),
+ },
+ {
+ name: "delete subscription",
+ mockedClient: mock.NewMockedHTTPClient(
+ mock.WithRequestMatch(
+ mock.DeleteReposSubscriptionByOwnerByRepo,
+ nil,
+ ),
+ ),
+ requestArgs: map[string]interface{}{
+ "owner": "owner",
+ "repo": "repo",
+ "action": "delete",
+ },
+ expectError: false,
+ expectDeleted: true,
+ },
+ {
+ name: "invalid action",
+ mockedClient: mock.NewMockedHTTPClient(),
+ requestArgs: map[string]interface{}{
+ "owner": "owner",
+ "repo": "repo",
+ "action": "invalid",
+ },
+ expectError: false,
+ expectInvalid: true,
+ },
+ {
+ name: "missing required owner",
+ mockedClient: mock.NewMockedHTTPClient(),
+ requestArgs: map[string]interface{}{
+ "repo": "repo",
+ "action": "ignore",
+ },
+ expectError: true,
+ },
+ {
+ name: "missing required repo",
+ mockedClient: mock.NewMockedHTTPClient(),
+ requestArgs: map[string]interface{}{
+ "owner": "owner",
+ "action": "ignore",
+ },
+ expectError: true,
+ },
+ {
+ name: "missing required action",
+ mockedClient: mock.NewMockedHTTPClient(),
+ requestArgs: map[string]interface{}{
+ "owner": "owner",
+ "repo": "repo",
+ },
+ expectError: true,
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ client := github.NewClient(tc.mockedClient)
+ _, handler := ManageRepositoryNotificationSubscription(stubGetClientFn(client), translations.NullTranslationHelper)
+ request := createMCPRequest(tc.requestArgs)
+ result, err := handler(context.Background(), request)
+
+ if tc.expectError {
+ require.NoError(t, err)
+ require.NotNil(t, result)
+ text := getTextResult(t, result).Text
+ switch {
+ case tc.requestArgs["owner"] == nil:
+ assert.Contains(t, text, "missing required parameter: owner")
+ case tc.requestArgs["repo"] == nil:
+ assert.Contains(t, text, "missing required parameter: repo")
+ case tc.requestArgs["action"] == nil:
+ assert.Contains(t, text, "missing required parameter: action")
+ default:
+ assert.Contains(t, text, "error")
+ }
+ return
+ }
+
+ require.NoError(t, err)
+ textContent := getTextResult(t, result)
+ if tc.expectIgnored != nil || tc.expectSubscribed != nil {
+ var returned github.Subscription
+ err = json.Unmarshal([]byte(textContent.Text), &returned)
+ require.NoError(t, err)
+ if tc.expectIgnored != nil {
+ assert.Equal(t, *tc.expectIgnored, *returned.Ignored)
+ }
+ if tc.expectSubscribed != nil {
+ assert.Equal(t, *tc.expectSubscribed, *returned.Subscribed)
+ }
+ }
+ if tc.expectDeleted {
+ assert.Contains(t, textContent.Text, "deleted")
+ }
+ if tc.expectInvalid {
+ assert.Contains(t, textContent.Text, "Invalid action")
+ }
+ })
+ }
+}
+
+func Test_DismissNotification(t *testing.T) {
+ // Verify tool definition and schema
+ mockClient := github.NewClient(nil)
+ tool, _ := DismissNotification(stubGetClientFn(mockClient), translations.NullTranslationHelper)
+ assert.Equal(t, "dismiss_notification", tool.Name)
+ assert.NotEmpty(t, tool.Description)
+ assert.Contains(t, tool.InputSchema.Properties, "threadID")
+ assert.Contains(t, tool.InputSchema.Properties, "state")
+ assert.ElementsMatch(t, tool.InputSchema.Required, []string{"threadID"})
+
+ tests := []struct {
+ name string
+ mockedClient *http.Client
+ requestArgs map[string]interface{}
+ expectError bool
+ expectRead bool
+ expectDone bool
+ expectInvalid bool
+ expectedErrMsg string
+ }{
+ {
+ name: "mark as read",
+ mockedClient: mock.NewMockedHTTPClient(
+ mock.WithRequestMatch(
+ mock.PatchNotificationsThreadsByThreadId,
+ nil,
+ ),
+ ),
+ requestArgs: map[string]interface{}{
+ "threadID": "123",
+ "state": "read",
+ },
+ expectError: false,
+ expectRead: true,
+ },
+ {
+ name: "mark as done",
+ mockedClient: mock.NewMockedHTTPClient(
+ mock.WithRequestMatch(
+ mock.DeleteNotificationsThreadsByThreadId,
+ nil,
+ ),
+ ),
+ requestArgs: map[string]interface{}{
+ "threadID": "123",
+ "state": "done",
+ },
+ expectError: false,
+ expectDone: true,
+ },
+ {
+ name: "invalid threadID format",
+ mockedClient: mock.NewMockedHTTPClient(),
+ requestArgs: map[string]interface{}{
+ "threadID": "notanumber",
+ "state": "done",
+ },
+ expectError: false,
+ expectInvalid: true,
+ },
+ {
+ name: "missing required threadID",
+ mockedClient: mock.NewMockedHTTPClient(),
+ requestArgs: map[string]interface{}{
+ "state": "read",
+ },
+ expectError: true,
+ },
+ {
+ name: "missing required state",
+ mockedClient: mock.NewMockedHTTPClient(),
+ requestArgs: map[string]interface{}{
+ "threadID": "123",
+ },
+ expectError: true,
+ },
+ {
+ name: "invalid state value",
+ mockedClient: mock.NewMockedHTTPClient(),
+ requestArgs: map[string]interface{}{
+ "threadID": "123",
+ "state": "invalid",
+ },
+ expectError: true,
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ client := github.NewClient(tc.mockedClient)
+ _, handler := DismissNotification(stubGetClientFn(client), translations.NullTranslationHelper)
+ request := createMCPRequest(tc.requestArgs)
+ result, err := handler(context.Background(), request)
+
+ if tc.expectError {
+ // The tool returns a ToolResultError with a specific message
+ require.NoError(t, err)
+ require.NotNil(t, result)
+ text := getTextResult(t, result).Text
+ switch {
+ case tc.requestArgs["threadID"] == nil:
+ assert.Contains(t, text, "missing required parameter: threadID")
+ case tc.requestArgs["state"] == nil:
+ assert.Contains(t, text, "missing required parameter: state")
+ case tc.name == "invalid threadID format":
+ assert.Contains(t, text, "invalid threadID format")
+ case tc.name == "invalid state value":
+ assert.Contains(t, text, "Invalid state. Must be one of: read, done.")
+ default:
+ // fallback for other errors
+ assert.Contains(t, text, "error")
+ }
+ return
+ }
+
+ require.NoError(t, err)
+ textContent := getTextResult(t, result)
+ if tc.expectRead {
+ assert.Contains(t, textContent.Text, "Notification marked as read")
+ }
+ if tc.expectDone {
+ assert.Contains(t, textContent.Text, "Notification marked as done")
+ }
+ if tc.expectInvalid {
+ assert.Contains(t, textContent.Text, "invalid threadID format")
+ }
+ })
+ }
+}
+
+func Test_MarkAllNotificationsRead(t *testing.T) {
+ // Verify tool definition and schema
+ mockClient := github.NewClient(nil)
+ tool, _ := MarkAllNotificationsRead(stubGetClientFn(mockClient), translations.NullTranslationHelper)
+ assert.Equal(t, "mark_all_notifications_read", tool.Name)
+ assert.NotEmpty(t, tool.Description)
+ assert.Contains(t, tool.InputSchema.Properties, "lastReadAt")
+ assert.Contains(t, tool.InputSchema.Properties, "owner")
+ assert.Contains(t, tool.InputSchema.Properties, "repo")
+ assert.Empty(t, tool.InputSchema.Required)
+
+ tests := []struct {
+ name string
+ mockedClient *http.Client
+ requestArgs map[string]interface{}
+ expectError bool
+ expectMarked bool
+ expectedErrMsg string
+ }{
+ {
+ name: "success (no params)",
+ mockedClient: mock.NewMockedHTTPClient(
+ mock.WithRequestMatch(
+ mock.PutNotifications,
+ nil,
+ ),
+ ),
+ requestArgs: map[string]interface{}{},
+ expectError: false,
+ expectMarked: true,
+ },
+ {
+ name: "success with lastReadAt param",
+ mockedClient: mock.NewMockedHTTPClient(
+ mock.WithRequestMatch(
+ mock.PutNotifications,
+ nil,
+ ),
+ ),
+ requestArgs: map[string]interface{}{
+ "lastReadAt": "2024-01-01T00:00:00Z",
+ },
+ expectError: false,
+ expectMarked: true,
+ },
+ {
+ name: "success with owner and repo",
+ mockedClient: mock.NewMockedHTTPClient(
+ mock.WithRequestMatch(
+ mock.PutReposNotificationsByOwnerByRepo,
+ nil,
+ ),
+ ),
+ requestArgs: map[string]interface{}{
+ "owner": "octocat",
+ "repo": "hello-world",
+ },
+ expectError: false,
+ expectMarked: true,
+ },
+ {
+ name: "API error",
+ mockedClient: mock.NewMockedHTTPClient(
+ mock.WithRequestMatchHandler(
+ mock.PutNotifications,
+ mockResponse(t, http.StatusInternalServerError, `{"message": "error"}`),
+ ),
+ ),
+ requestArgs: map[string]interface{}{},
+ expectError: true,
+ expectedErrMsg: "error",
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ client := github.NewClient(tc.mockedClient)
+ _, handler := MarkAllNotificationsRead(stubGetClientFn(client), translations.NullTranslationHelper)
+ request := createMCPRequest(tc.requestArgs)
+ result, err := handler(context.Background(), request)
+
+ if tc.expectError {
+ require.Error(t, err)
+ if tc.expectedErrMsg != "" {
+ assert.Contains(t, err.Error(), tc.expectedErrMsg)
+ }
+ return
+ }
+
+ require.NoError(t, err)
+ textContent := getTextResult(t, result)
+ if tc.expectMarked {
+ assert.Contains(t, textContent.Text, "All notifications marked as read")
+ }
+ })
+ }
+}
+
+func Test_GetNotificationDetails(t *testing.T) {
+ // Verify tool definition and schema
+ mockClient := github.NewClient(nil)
+ tool, _ := GetNotificationDetails(stubGetClientFn(mockClient), translations.NullTranslationHelper)
+ assert.Equal(t, "get_notification_details", tool.Name)
+ assert.NotEmpty(t, tool.Description)
+ assert.Contains(t, tool.InputSchema.Properties, "notificationID")
+ assert.ElementsMatch(t, tool.InputSchema.Required, []string{"notificationID"})
+
+ mockThread := &github.Notification{ID: github.Ptr("123"), Reason: github.Ptr("mention")}
+
+ tests := []struct {
+ name string
+ mockedClient *http.Client
+ requestArgs map[string]interface{}
+ expectError bool
+ expectResult *github.Notification
+ expectedErrMsg string
+ }{
+ {
+ name: "success",
+ mockedClient: mock.NewMockedHTTPClient(
+ mock.WithRequestMatch(
+ mock.GetNotificationsThreadsByThreadId,
+ mockThread,
+ ),
+ ),
+ requestArgs: map[string]interface{}{
+ "notificationID": "123",
+ },
+ expectError: false,
+ expectResult: mockThread,
+ },
+ {
+ name: "not found",
+ mockedClient: mock.NewMockedHTTPClient(
+ mock.WithRequestMatchHandler(
+ mock.GetNotificationsThreadsByThreadId,
+ mockResponse(t, http.StatusNotFound, `{"message": "not found"}`),
+ ),
+ ),
+ requestArgs: map[string]interface{}{
+ "notificationID": "123",
+ },
+ expectError: true,
+ expectedErrMsg: "not found",
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ client := github.NewClient(tc.mockedClient)
+ _, handler := GetNotificationDetails(stubGetClientFn(client), translations.NullTranslationHelper)
+ request := createMCPRequest(tc.requestArgs)
+ result, err := handler(context.Background(), request)
+
+ if tc.expectError {
+ require.Error(t, err)
+ if tc.expectedErrMsg != "" {
+ assert.Contains(t, err.Error(), tc.expectedErrMsg)
+ }
+ return
+ }
+
+ require.NoError(t, err)
+ textContent := getTextResult(t, result)
+ var returned github.Notification
+ err = json.Unmarshal([]byte(textContent.Text), &returned)
+ require.NoError(t, err)
+ assert.Equal(t, *tc.expectResult.ID, *returned.ID)
+ })
+ }
+}
diff --git a/pkg/github/pullrequests.go b/pkg/github/pullrequests.go
index 1ecd209e5..b16920aa2 100644
--- a/pkg/github/pullrequests.go
+++ b/pkg/github/pullrequests.go
@@ -7,16 +7,23 @@ import (
"io"
"net/http"
- "github.com/github/github-mcp-server/pkg/translations"
- "github.com/google/go-github/v69/github"
+ "github.com/go-viper/mapstructure/v2"
+ "github.com/google/go-github/v72/github"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
+ "github.com/shurcooL/githubv4"
+
+ "github.com/github/github-mcp-server/pkg/translations"
)
// GetPullRequest creates a tool to get details of a specific pull request.
-func GetPullRequest(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
+func GetPullRequest(getClient GetClientFn, t translations.TranslationHelperFunc) (mcp.Tool, server.ToolHandlerFunc) {
return mcp.NewTool("get_pull_request",
- mcp.WithDescription(t("TOOL_GET_PULL_REQUEST_DESCRIPTION", "Get details of a specific pull request")),
+ mcp.WithDescription(t("TOOL_GET_PULL_REQUEST_DESCRIPTION", "Get details of a specific pull request in a GitHub repository.")),
+ mcp.WithToolAnnotation(mcp.ToolAnnotation{
+ Title: t("TOOL_GET_PULL_REQUEST_USER_TITLE", "Get pull request details"),
+ ReadOnlyHint: ToBoolPtr(true),
+ }),
mcp.WithString("owner",
mcp.Required(),
mcp.Description("Repository owner"),
@@ -31,11 +38,11 @@ func GetPullRequest(getClient GetClientFn, t translations.TranslationHelperFunc)
),
),
func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
- owner, err := requiredParam[string](request, "owner")
+ owner, err := RequiredParam[string](request, "owner")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
- repo, err := requiredParam[string](request, "repo")
+ repo, err := RequiredParam[string](request, "repo")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
@@ -71,10 +78,129 @@ func GetPullRequest(getClient GetClientFn, t translations.TranslationHelperFunc)
}
}
+// CreatePullRequest creates a tool to create a new pull request.
+func CreatePullRequest(getClient GetClientFn, t translations.TranslationHelperFunc) (mcp.Tool, server.ToolHandlerFunc) {
+ return mcp.NewTool("create_pull_request",
+ mcp.WithDescription(t("TOOL_CREATE_PULL_REQUEST_DESCRIPTION", "Create a new pull request in a GitHub repository.")),
+ mcp.WithToolAnnotation(mcp.ToolAnnotation{
+ Title: t("TOOL_CREATE_PULL_REQUEST_USER_TITLE", "Open new pull request"),
+ ReadOnlyHint: ToBoolPtr(false),
+ }),
+ mcp.WithString("owner",
+ mcp.Required(),
+ mcp.Description("Repository owner"),
+ ),
+ mcp.WithString("repo",
+ mcp.Required(),
+ mcp.Description("Repository name"),
+ ),
+ mcp.WithString("title",
+ mcp.Required(),
+ mcp.Description("PR title"),
+ ),
+ mcp.WithString("body",
+ mcp.Description("PR description"),
+ ),
+ mcp.WithString("head",
+ mcp.Required(),
+ mcp.Description("Branch containing changes"),
+ ),
+ mcp.WithString("base",
+ mcp.Required(),
+ mcp.Description("Branch to merge into"),
+ ),
+ mcp.WithBoolean("draft",
+ mcp.Description("Create as draft PR"),
+ ),
+ mcp.WithBoolean("maintainer_can_modify",
+ mcp.Description("Allow maintainer edits"),
+ ),
+ ),
+ 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
+ }
+ title, err := RequiredParam[string](request, "title")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+ head, err := RequiredParam[string](request, "head")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+ base, err := RequiredParam[string](request, "base")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+
+ body, err := OptionalParam[string](request, "body")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+
+ draft, err := OptionalParam[bool](request, "draft")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+
+ maintainerCanModify, err := OptionalParam[bool](request, "maintainer_can_modify")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+
+ newPR := &github.NewPullRequest{
+ Title: github.Ptr(title),
+ Head: github.Ptr(head),
+ Base: github.Ptr(base),
+ }
+
+ if body != "" {
+ newPR.Body = github.Ptr(body)
+ }
+
+ newPR.Draft = github.Ptr(draft)
+ newPR.MaintainerCanModify = github.Ptr(maintainerCanModify)
+
+ client, err := getClient(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get GitHub client: %w", err)
+ }
+ pr, resp, err := client.PullRequests.Create(ctx, owner, repo, newPR)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create pull request: %w", err)
+ }
+ defer func() { _ = resp.Body.Close() }()
+
+ if resp.StatusCode != http.StatusCreated {
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, fmt.Errorf("failed to read response body: %w", err)
+ }
+ return mcp.NewToolResultError(fmt.Sprintf("failed to create pull request: %s", string(body))), nil
+ }
+
+ r, err := json.Marshal(pr)
+ if err != nil {
+ return nil, fmt.Errorf("failed to marshal response: %w", err)
+ }
+
+ return mcp.NewToolResultText(string(r)), nil
+ }
+}
+
// UpdatePullRequest creates a tool to update an existing pull request.
-func UpdatePullRequest(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
+func UpdatePullRequest(getClient GetClientFn, t translations.TranslationHelperFunc) (mcp.Tool, server.ToolHandlerFunc) {
return mcp.NewTool("update_pull_request",
- mcp.WithDescription(t("TOOL_UPDATE_PULL_REQUEST_DESCRIPTION", "Update an existing pull request in a GitHub repository")),
+ mcp.WithDescription(t("TOOL_UPDATE_PULL_REQUEST_DESCRIPTION", "Update an existing pull request in a GitHub repository.")),
+ mcp.WithToolAnnotation(mcp.ToolAnnotation{
+ Title: t("TOOL_UPDATE_PULL_REQUEST_USER_TITLE", "Edit pull request"),
+ ReadOnlyHint: ToBoolPtr(false),
+ }),
mcp.WithString("owner",
mcp.Required(),
mcp.Description("Repository owner"),
@@ -105,11 +231,11 @@ func UpdatePullRequest(getClient GetClientFn, t translations.TranslationHelperFu
),
),
func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
- owner, err := requiredParam[string](request, "owner")
+ owner, err := RequiredParam[string](request, "owner")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
- repo, err := requiredParam[string](request, "repo")
+ repo, err := RequiredParam[string](request, "repo")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
@@ -189,9 +315,13 @@ 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) (tool mcp.Tool, handler server.ToolHandlerFunc) {
+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 and filter repository pull requests")),
+ mcp.WithDescription(t("TOOL_LIST_PULL_REQUESTS_DESCRIPTION", "List pull requests in a GitHub repository.")),
+ mcp.WithToolAnnotation(mcp.ToolAnnotation{
+ Title: t("TOOL_LIST_PULL_REQUESTS_USER_TITLE", "List pull requests"),
+ ReadOnlyHint: ToBoolPtr(true),
+ }),
mcp.WithString("owner",
mcp.Required(),
mcp.Description("Repository owner"),
@@ -221,11 +351,11 @@ func ListPullRequests(getClient GetClientFn, t translations.TranslationHelperFun
WithPagination(),
),
func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
- owner, err := requiredParam[string](request, "owner")
+ owner, err := RequiredParam[string](request, "owner")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
- repo, err := requiredParam[string](request, "repo")
+ repo, err := RequiredParam[string](request, "repo")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
@@ -294,9 +424,13 @@ func ListPullRequests(getClient GetClientFn, t translations.TranslationHelperFun
}
// MergePullRequest creates a tool to merge a pull request.
-func MergePullRequest(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
+func MergePullRequest(getClient GetClientFn, t translations.TranslationHelperFunc) (mcp.Tool, server.ToolHandlerFunc) {
return mcp.NewTool("merge_pull_request",
- mcp.WithDescription(t("TOOL_MERGE_PULL_REQUEST_DESCRIPTION", "Merge a pull request")),
+ mcp.WithDescription(t("TOOL_MERGE_PULL_REQUEST_DESCRIPTION", "Merge a pull request in a GitHub repository.")),
+ mcp.WithToolAnnotation(mcp.ToolAnnotation{
+ Title: t("TOOL_MERGE_PULL_REQUEST_USER_TITLE", "Merge pull request"),
+ ReadOnlyHint: ToBoolPtr(false),
+ }),
mcp.WithString("owner",
mcp.Required(),
mcp.Description("Repository owner"),
@@ -321,11 +455,11 @@ func MergePullRequest(getClient GetClientFn, t translations.TranslationHelperFun
),
),
func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
- owner, err := requiredParam[string](request, "owner")
+ owner, err := RequiredParam[string](request, "owner")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
- repo, err := requiredParam[string](request, "repo")
+ repo, err := RequiredParam[string](request, "repo")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
@@ -379,9 +513,13 @@ func MergePullRequest(getClient GetClientFn, t translations.TranslationHelperFun
}
// GetPullRequestFiles creates a tool to get the list of files changed in a pull request.
-func GetPullRequestFiles(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
+func GetPullRequestFiles(getClient GetClientFn, t translations.TranslationHelperFunc) (mcp.Tool, server.ToolHandlerFunc) {
return mcp.NewTool("get_pull_request_files",
- mcp.WithDescription(t("TOOL_GET_PULL_REQUEST_FILES_DESCRIPTION", "Get the list of files changed in a pull request")),
+ mcp.WithDescription(t("TOOL_GET_PULL_REQUEST_FILES_DESCRIPTION", "Get the files changed in a specific pull request.")),
+ mcp.WithToolAnnotation(mcp.ToolAnnotation{
+ Title: t("TOOL_GET_PULL_REQUEST_FILES_USER_TITLE", "Get pull request files"),
+ ReadOnlyHint: ToBoolPtr(true),
+ }),
mcp.WithString("owner",
mcp.Required(),
mcp.Description("Repository owner"),
@@ -396,11 +534,11 @@ func GetPullRequestFiles(getClient GetClientFn, t translations.TranslationHelper
),
),
func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
- owner, err := requiredParam[string](request, "owner")
+ owner, err := RequiredParam[string](request, "owner")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
- repo, err := requiredParam[string](request, "repo")
+ repo, err := RequiredParam[string](request, "repo")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
@@ -438,9 +576,13 @@ func GetPullRequestFiles(getClient GetClientFn, t translations.TranslationHelper
}
// GetPullRequestStatus creates a tool to get the combined status of all status checks for a pull request.
-func GetPullRequestStatus(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
+func GetPullRequestStatus(getClient GetClientFn, t translations.TranslationHelperFunc) (mcp.Tool, server.ToolHandlerFunc) {
return mcp.NewTool("get_pull_request_status",
- mcp.WithDescription(t("TOOL_GET_PULL_REQUEST_STATUS_DESCRIPTION", "Get the combined status of all status checks for a pull request")),
+ mcp.WithDescription(t("TOOL_GET_PULL_REQUEST_STATUS_DESCRIPTION", "Get the status of a specific pull request.")),
+ mcp.WithToolAnnotation(mcp.ToolAnnotation{
+ Title: t("TOOL_GET_PULL_REQUEST_STATUS_USER_TITLE", "Get pull request status checks"),
+ ReadOnlyHint: ToBoolPtr(true),
+ }),
mcp.WithString("owner",
mcp.Required(),
mcp.Description("Repository owner"),
@@ -455,11 +597,11 @@ func GetPullRequestStatus(getClient GetClientFn, t translations.TranslationHelpe
),
),
func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
- owner, err := requiredParam[string](request, "owner")
+ owner, err := RequiredParam[string](request, "owner")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
- repo, err := requiredParam[string](request, "repo")
+ repo, err := RequiredParam[string](request, "repo")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
@@ -511,9 +653,13 @@ func GetPullRequestStatus(getClient GetClientFn, t translations.TranslationHelpe
}
// UpdatePullRequestBranch creates a tool to update a pull request branch with the latest changes from the base branch.
-func UpdatePullRequestBranch(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
+func UpdatePullRequestBranch(getClient GetClientFn, t translations.TranslationHelperFunc) (mcp.Tool, server.ToolHandlerFunc) {
return mcp.NewTool("update_pull_request_branch",
- mcp.WithDescription(t("TOOL_UPDATE_PULL_REQUEST_BRANCH_DESCRIPTION", "Update a pull request branch with the latest changes from the base branch")),
+ mcp.WithDescription(t("TOOL_UPDATE_PULL_REQUEST_BRANCH_DESCRIPTION", "Update the branch of a pull request with the latest changes from the base branch.")),
+ mcp.WithToolAnnotation(mcp.ToolAnnotation{
+ Title: t("TOOL_UPDATE_PULL_REQUEST_BRANCH_USER_TITLE", "Update pull request branch"),
+ ReadOnlyHint: ToBoolPtr(false),
+ }),
mcp.WithString("owner",
mcp.Required(),
mcp.Description("Repository owner"),
@@ -531,11 +677,11 @@ func UpdatePullRequestBranch(getClient GetClientFn, t translations.TranslationHe
),
),
func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
- owner, err := requiredParam[string](request, "owner")
+ owner, err := RequiredParam[string](request, "owner")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
- repo, err := requiredParam[string](request, "repo")
+ repo, err := RequiredParam[string](request, "repo")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
@@ -585,9 +731,13 @@ func UpdatePullRequestBranch(getClient GetClientFn, t translations.TranslationHe
}
// GetPullRequestComments creates a tool to get the review comments on a pull request.
-func GetPullRequestComments(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
+func GetPullRequestComments(getClient GetClientFn, t translations.TranslationHelperFunc) (mcp.Tool, server.ToolHandlerFunc) {
return mcp.NewTool("get_pull_request_comments",
- mcp.WithDescription(t("TOOL_GET_PULL_REQUEST_COMMENTS_DESCRIPTION", "Get the review comments on a pull request")),
+ mcp.WithDescription(t("TOOL_GET_PULL_REQUEST_COMMENTS_DESCRIPTION", "Get comments for a specific pull request.")),
+ mcp.WithToolAnnotation(mcp.ToolAnnotation{
+ Title: t("TOOL_GET_PULL_REQUEST_COMMENTS_USER_TITLE", "Get pull request comments"),
+ ReadOnlyHint: ToBoolPtr(true),
+ }),
mcp.WithString("owner",
mcp.Required(),
mcp.Description("Repository owner"),
@@ -602,11 +752,11 @@ func GetPullRequestComments(getClient GetClientFn, t translations.TranslationHel
),
),
func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
- owner, err := requiredParam[string](request, "owner")
+ owner, err := RequiredParam[string](request, "owner")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
- repo, err := requiredParam[string](request, "repo")
+ repo, err := RequiredParam[string](request, "repo")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
@@ -648,10 +798,14 @@ func GetPullRequestComments(getClient GetClientFn, t translations.TranslationHel
}
}
-// AddPullRequestReviewComment creates a tool to add a review comment to a pull request.
-func AddPullRequestReviewComment(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
- return mcp.NewTool("add_pull_request_review_comment",
- mcp.WithDescription(t("TOOL_ADD_PULL_REQUEST_COMMENT_DESCRIPTION", "Add a review comment to a pull request")),
+// GetPullRequestReviews creates a tool to get the reviews on a pull request.
+func GetPullRequestReviews(getClient GetClientFn, t translations.TranslationHelperFunc) (mcp.Tool, server.ToolHandlerFunc) {
+ return mcp.NewTool("get_pull_request_reviews",
+ mcp.WithDescription(t("TOOL_GET_PULL_REQUEST_REVIEWS_DESCRIPTION", "Get reviews for a specific pull request.")),
+ mcp.WithToolAnnotation(mcp.ToolAnnotation{
+ Title: t("TOOL_GET_PULL_REQUEST_REVIEWS_USER_TITLE", "Get pull request reviews"),
+ ReadOnlyHint: ToBoolPtr(true),
+ }),
mcp.WithString("owner",
mcp.Required(),
mcp.Description("Repository owner"),
@@ -660,56 +814,21 @@ func AddPullRequestReviewComment(getClient GetClientFn, t translations.Translati
mcp.Required(),
mcp.Description("Repository name"),
),
- mcp.WithNumber("pull_number",
+ mcp.WithNumber("pullNumber",
mcp.Required(),
mcp.Description("Pull request number"),
),
- mcp.WithString("body",
- mcp.Required(),
- mcp.Description("The text of the review comment"),
- ),
- mcp.WithString("commit_id",
- mcp.Description("The SHA of the commit to comment on. Required unless in_reply_to is specified."),
- ),
- mcp.WithString("path",
- mcp.Description("The relative path to the file that necessitates a comment. Required unless in_reply_to is specified."),
- ),
- mcp.WithString("subject_type",
- mcp.Description("The level at which the comment is targeted"),
- mcp.Enum("line", "file"),
- ),
- mcp.WithNumber("line",
- mcp.Description("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"),
- ),
- mcp.WithString("side",
- mcp.Description("The side of the diff to comment on"),
- mcp.Enum("LEFT", "RIGHT"),
- ),
- mcp.WithNumber("start_line",
- mcp.Description("For multi-line comments, the first line of the range that the comment applies to"),
- ),
- mcp.WithString("start_side",
- mcp.Description("For multi-line comments, the starting side of the diff that the comment applies to"),
- mcp.Enum("LEFT", "RIGHT"),
- ),
- mcp.WithNumber("in_reply_to",
- mcp.Description("The ID of the review comment to reply to. When specified, only body is required and all other parameters are ignored"),
- ),
),
func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
- owner, err := requiredParam[string](request, "owner")
+ owner, err := RequiredParam[string](request, "owner")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
- repo, err := requiredParam[string](request, "repo")
+ repo, err := RequiredParam[string](request, "repo")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
- pullNumber, err := RequiredInt(request, "pull_number")
- if err != nil {
- return mcp.NewToolResultError(err.Error()), nil
- }
- body, err := requiredParam[string](request, "body")
+ pullNumber, err := RequiredInt(request, "pullNumber")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
@@ -718,110 +837,234 @@ func AddPullRequestReviewComment(getClient GetClientFn, t translations.Translati
if err != nil {
return nil, fmt.Errorf("failed to get GitHub client: %w", err)
}
+ reviews, resp, err := client.PullRequests.ListReviews(ctx, owner, repo, pullNumber, nil)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get pull request reviews: %w", err)
+ }
+ defer func() { _ = resp.Body.Close() }()
- // Check if this is a reply to an existing comment
- if replyToFloat, ok := request.Params.Arguments["in_reply_to"].(float64); ok {
- // Use the specialized method for reply comments due to inconsistency in underlying go-github library: https://github.com/google/go-github/pull/950
- commentID := int64(replyToFloat)
- createdReply, resp, err := client.PullRequests.CreateCommentInReplyTo(ctx, owner, repo, pullNumber, body, commentID)
+ if resp.StatusCode != http.StatusOK {
+ body, err := io.ReadAll(resp.Body)
if err != nil {
- return nil, fmt.Errorf("failed to reply to pull request comment: %w", err)
+ return nil, fmt.Errorf("failed to read response body: %w", err)
}
- defer func() { _ = resp.Body.Close() }()
+ return mcp.NewToolResultError(fmt.Sprintf("failed to get pull request reviews: %s", string(body))), nil
+ }
- if resp.StatusCode != http.StatusCreated {
- respBody, 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 reply to pull request comment: %s", string(respBody))), nil
- }
+ r, err := json.Marshal(reviews)
+ if err != nil {
+ return nil, fmt.Errorf("failed to marshal response: %w", err)
+ }
- r, err := json.Marshal(createdReply)
- if err != nil {
- return nil, fmt.Errorf("failed to marshal response: %w", err)
- }
+ return mcp.NewToolResultText(string(r)), nil
+ }
+}
- return mcp.NewToolResultText(string(r)), nil
+func CreateAndSubmitPullRequestReview(getGQLClient GetGQLClientFn, t translations.TranslationHelperFunc) (mcp.Tool, server.ToolHandlerFunc) {
+ return mcp.NewTool("create_and_submit_pull_request_review",
+ mcp.WithDescription(t("TOOL_CREATE_AND_SUBMIT_PULL_REQUEST_REVIEW_DESCRIPTION", "Create and submit a review for a pull request without review comments.")),
+ mcp.WithToolAnnotation(mcp.ToolAnnotation{
+ Title: t("TOOL_CREATE_AND_SUBMIT_PULL_REQUEST_REVIEW_USER_TITLE", "Create and submit a pull request review without comments"),
+ ReadOnlyHint: ToBoolPtr(false),
+ }),
+ // Either we need the PR GQL Id directly, or we need owner, repo and PR number to look it up.
+ // Since our other Pull Request tools are working with the REST Client, will handle the lookup
+ // internally for now.
+ mcp.WithString("owner",
+ mcp.Required(),
+ mcp.Description("Repository owner"),
+ ),
+ mcp.WithString("repo",
+ mcp.Required(),
+ mcp.Description("Repository name"),
+ ),
+ mcp.WithNumber("pullNumber",
+ mcp.Required(),
+ mcp.Description("Pull request number"),
+ ),
+ mcp.WithString("body",
+ mcp.Required(),
+ mcp.Description("Review comment text"),
+ ),
+ mcp.WithString("event",
+ mcp.Required(),
+ mcp.Description("Review action to perform"),
+ mcp.Enum("APPROVE", "REQUEST_CHANGES", "COMMENT"),
+ ),
+ mcp.WithString("commitID",
+ mcp.Description("SHA of commit to review"),
+ ),
+ ),
+ func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
+ var params struct {
+ Owner string
+ Repo string
+ PullNumber int32
+ Body string
+ Event string
+ CommitID *string
+ }
+ if err := mapstructure.Decode(request.Params.Arguments, ¶ms); err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
}
- // This is a new comment, not a reply
- // Verify required parameters for a new comment
- commitID, err := requiredParam[string](request, "commit_id")
+ // Given our owner, repo and PR number, lookup the GQL ID of the PR.
+ client, err := getGQLClient(ctx)
if err != nil {
- return mcp.NewToolResultError(err.Error()), nil
+ return mcp.NewToolResultError(fmt.Sprintf("failed to get GitHub GQL client: %v", err)), nil
}
- path, err := requiredParam[string](request, "path")
- if err != nil {
+
+ var getPullRequestQuery struct {
+ Repository struct {
+ PullRequest struct {
+ ID githubv4.ID
+ } `graphql:"pullRequest(number: $prNum)"`
+ } `graphql:"repository(owner: $owner, name: $repo)"`
+ }
+ if err := client.Query(ctx, &getPullRequestQuery, map[string]any{
+ "owner": githubv4.String(params.Owner),
+ "repo": githubv4.String(params.Repo),
+ "prNum": githubv4.Int(params.PullNumber),
+ }); err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
- comment := &github.PullRequestComment{
- Body: github.Ptr(body),
- CommitID: github.Ptr(commitID),
- Path: github.Ptr(path),
+ // Now we have the GQL ID, we can create a review
+ var addPullRequestReviewMutation struct {
+ AddPullRequestReview struct {
+ PullRequestReview struct {
+ ID githubv4.ID // We don't need this, but a selector is required or GQL complains.
+ }
+ } `graphql:"addPullRequestReview(input: $input)"`
}
- subjectType, err := OptionalParam[string](request, "subject_type")
- if err != nil {
+ if err := client.Mutate(
+ ctx,
+ &addPullRequestReviewMutation,
+ githubv4.AddPullRequestReviewInput{
+ PullRequestID: getPullRequestQuery.Repository.PullRequest.ID,
+ Body: githubv4.NewString(githubv4.String(params.Body)),
+ Event: newGQLStringlike[githubv4.PullRequestReviewEvent](params.Event),
+ CommitOID: newGQLStringlikePtr[githubv4.GitObjectID](params.CommitID),
+ },
+ nil,
+ ); err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
- if subjectType != "file" {
- line, lineExists := request.Params.Arguments["line"].(float64)
- startLine, startLineExists := request.Params.Arguments["start_line"].(float64)
- side, sideExists := request.Params.Arguments["side"].(string)
- startSide, startSideExists := request.Params.Arguments["start_side"].(string)
- if !lineExists {
- return mcp.NewToolResultError("line parameter is required unless using subject_type:file"), nil
- }
-
- comment.Line = github.Ptr(int(line))
- if sideExists {
- comment.Side = github.Ptr(side)
- }
- if startLineExists {
- comment.StartLine = github.Ptr(int(startLine))
- }
- if startSideExists {
- comment.StartSide = github.Ptr(startSide)
- }
+ // Return nothing interesting, just indicate success for the time being.
+ // In future, we may want to return the review ID, but for the moment, we're not leaking
+ // API implementation details to the LLM.
+ return mcp.NewToolResultText("pull request review submitted successfully"), nil
+ }
+}
- if startLineExists && !lineExists {
- return mcp.NewToolResultError("if start_line is provided, line must also be provided"), nil
- }
- if startSideExists && !sideExists {
- return mcp.NewToolResultError("if start_side is provided, side must also be provided"), nil
- }
+// CreatePendingPullRequestReview creates a tool to create a pending review on a pull request.
+func CreatePendingPullRequestReview(getGQLClient GetGQLClientFn, t translations.TranslationHelperFunc) (mcp.Tool, server.ToolHandlerFunc) {
+ return mcp.NewTool("create_pending_pull_request_review",
+ mcp.WithDescription(t("TOOL_CREATE_PENDING_PULL_REQUEST_REVIEW_DESCRIPTION", "Create a pending review for a pull request. Call this first before attempting to add comments to a pending review, and ultimately submitting it. A pending pull request review means a pull request review, it is pending because you create it first and submit it later, and the PR author will not see it until it is submitted.")),
+ mcp.WithToolAnnotation(mcp.ToolAnnotation{
+ Title: t("TOOL_CREATE_PENDING_PULL_REQUEST_REVIEW_USER_TITLE", "Create pending pull request review"),
+ ReadOnlyHint: ToBoolPtr(false),
+ }),
+ // Either we need the PR GQL Id directly, or we need owner, repo and PR number to look it up.
+ // Since our other Pull Request tools are working with the REST Client, will handle the lookup
+ // internally for now.
+ mcp.WithString("owner",
+ mcp.Required(),
+ mcp.Description("Repository owner"),
+ ),
+ mcp.WithString("repo",
+ mcp.Required(),
+ mcp.Description("Repository name"),
+ ),
+ mcp.WithNumber("pullNumber",
+ mcp.Required(),
+ mcp.Description("Pull request number"),
+ ),
+ mcp.WithString("commitID",
+ mcp.Description("SHA of commit to review"),
+ ),
+ // Event is omitted here because we always want to create a pending review.
+ // Threads are omitted for the moment, and we'll see if the LLM can use the appropriate tool.
+ ),
+ func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
+ var params struct {
+ Owner string
+ Repo string
+ PullNumber int32
+ CommitID *string
+ }
+ if err := mapstructure.Decode(request.Params.Arguments, ¶ms); err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
}
- createdComment, resp, err := client.PullRequests.CreateComment(ctx, owner, repo, pullNumber, comment)
+ // Given our owner, repo and PR number, lookup the GQL ID of the PR.
+ client, err := getGQLClient(ctx)
if err != nil {
- return nil, fmt.Errorf("failed to create pull request comment: %w", err)
+ return mcp.NewToolResultError(fmt.Sprintf("failed to get GitHub GQL client: %v", err)), nil
}
- defer func() { _ = resp.Body.Close() }()
- if resp.StatusCode != http.StatusCreated {
- respBody, err := io.ReadAll(resp.Body)
- if err != nil {
- return nil, fmt.Errorf("failed to read response body: %w", err)
- }
- return mcp.NewToolResultError(fmt.Sprintf("failed to create pull request comment: %s", string(respBody))), nil
+ var getPullRequestQuery struct {
+ Repository struct {
+ PullRequest struct {
+ ID githubv4.ID
+ } `graphql:"pullRequest(number: $prNum)"`
+ } `graphql:"repository(owner: $owner, name: $repo)"`
+ }
+ if err := client.Query(ctx, &getPullRequestQuery, map[string]any{
+ "owner": githubv4.String(params.Owner),
+ "repo": githubv4.String(params.Repo),
+ "prNum": githubv4.Int(params.PullNumber),
+ }); err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
}
- r, err := json.Marshal(createdComment)
- if err != nil {
- return nil, fmt.Errorf("failed to marshal response: %w", err)
+ // Now we have the GQL ID, we can create a pending review
+ var addPullRequestReviewMutation struct {
+ AddPullRequestReview struct {
+ PullRequestReview struct {
+ ID githubv4.ID // We don't need this, but a selector is required or GQL complains.
+ }
+ } `graphql:"addPullRequestReview(input: $input)"`
}
- return mcp.NewToolResultText(string(r)), nil
+ if err := client.Mutate(
+ ctx,
+ &addPullRequestReviewMutation,
+ githubv4.AddPullRequestReviewInput{
+ PullRequestID: getPullRequestQuery.Repository.PullRequest.ID,
+ CommitOID: newGQLStringlikePtr[githubv4.GitObjectID](params.CommitID),
+ },
+ nil,
+ ); err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+
+ // Return nothing interesting, just indicate success for the time being.
+ // In future, we may want to return the review ID, but for the moment, we're not leaking
+ // API implementation details to the LLM.
+ return mcp.NewToolResultText("pending pull request created"), nil
}
}
-// GetPullRequestReviews creates a tool to get the reviews on a pull request.
-func GetPullRequestReviews(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
- return mcp.NewTool("get_pull_request_reviews",
- mcp.WithDescription(t("TOOL_GET_PULL_REQUEST_REVIEWS_DESCRIPTION", "Get the reviews on a pull request")),
+// 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).")),
+ 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"),
+ ReadOnlyHint: ToBoolPtr(false),
+ }),
+ // Ideally, for performance sake this would just accept the pullRequestReviewID. However, we would need to
+ // add a new tool to get that ID for clients that aren't in the same context as the original pending review
+ // creation. So for now, we'll just accept the owner, repo and pull number and assume this is adding a comment
+ // the latest review from a user, since only one can be active at a time. It can later be extended with
+ // a pullRequestReviewID parameter if targeting other reviews is desired:
+ // mcp.WithString("pullRequestReviewID",
+ // mcp.Required(),
+ // mcp.Description("The ID of the pull request review to add a comment to"),
+ // ),
mcp.WithString("owner",
mcp.Required(),
mcp.Description("Repository owner"),
@@ -834,52 +1077,149 @@ func GetPullRequestReviews(getClient GetClientFn, t translations.TranslationHelp
mcp.Required(),
mcp.Description("Pull request number"),
),
+ mcp.WithString("path",
+ mcp.Required(),
+ mcp.Description("The relative path to the file that necessitates a comment"),
+ ),
+ mcp.WithString("body",
+ mcp.Required(),
+ mcp.Description("The text of the review comment"),
+ ),
+ mcp.WithString("subjectType",
+ mcp.Required(),
+ mcp.Description("The level at which the comment is targeted"),
+ mcp.Enum("FILE", "LINE"),
+ ),
+ mcp.WithNumber("line",
+ mcp.Description("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"),
+ ),
+ mcp.WithString("side",
+ mcp.Description("The side of the diff to comment on. LEFT indicates the previous state, RIGHT indicates the new state"),
+ mcp.Enum("LEFT", "RIGHT"),
+ ),
+ mcp.WithNumber("startLine",
+ mcp.Description("For multi-line comments, the first line of the range that the comment applies to"),
+ ),
+ mcp.WithString("startSide",
+ mcp.Description("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"),
+ mcp.Enum("LEFT", "RIGHT"),
+ ),
),
func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
- owner, err := requiredParam[string](request, "owner")
- if err != nil {
+ var params struct {
+ Owner string
+ Repo string
+ PullNumber int32
+ Path string
+ Body string
+ SubjectType string
+ Line *int32
+ Side *string
+ StartLine *int32
+ StartSide *string
+ }
+ if err := mapstructure.Decode(request.Params.Arguments, ¶ms); err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
- repo, err := requiredParam[string](request, "repo")
+
+ client, err := getGQLClient(ctx)
if err != nil {
- return mcp.NewToolResultError(err.Error()), nil
+ return nil, fmt.Errorf("failed to get GitHub GQL client: %w", err)
}
- pullNumber, err := RequiredInt(request, "pullNumber")
- if err != nil {
+
+ // First we'll get the current user
+ var getViewerQuery struct {
+ Viewer struct {
+ Login githubv4.String
+ }
+ }
+
+ if err := client.Query(ctx, &getViewerQuery, nil); 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)
+ var getLatestReviewForViewerQuery struct {
+ Repository struct {
+ PullRequest struct {
+ Reviews struct {
+ Nodes []struct {
+ ID githubv4.ID
+ State githubv4.PullRequestReviewState
+ URL githubv4.URI
+ }
+ } `graphql:"reviews(first: 1, author: $author)"`
+ } `graphql:"pullRequest(number: $prNum)"`
+ } `graphql:"repository(owner: $owner, name: $name)"`
}
- reviews, resp, err := client.PullRequests.ListReviews(ctx, owner, repo, pullNumber, nil)
- if err != nil {
- return nil, fmt.Errorf("failed to get pull request reviews: %w", err)
+
+ vars := map[string]any{
+ "author": githubv4.String(getViewerQuery.Viewer.Login),
+ "owner": githubv4.String(params.Owner),
+ "name": githubv4.String(params.Repo),
+ "prNum": githubv4.Int(params.PullNumber),
}
- 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 pull request reviews: %s", string(body))), nil
+ if err := client.Query(context.Background(), &getLatestReviewForViewerQuery, vars); err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
}
- r, err := json.Marshal(reviews)
- if err != nil {
- return nil, fmt.Errorf("failed to marshal response: %w", err)
+ // Validate there is one review and the state is pending
+ if len(getLatestReviewForViewerQuery.Repository.PullRequest.Reviews.Nodes) == 0 {
+ return mcp.NewToolResultError("No pending review found for the viewer"), nil
}
- return mcp.NewToolResultText(string(r)), nil
+ review := getLatestReviewForViewerQuery.Repository.PullRequest.Reviews.Nodes[0]
+ if review.State != githubv4.PullRequestReviewStatePending {
+ errText := fmt.Sprintf("The latest review, found at %s is not pending", review.URL)
+ return mcp.NewToolResultError(errText), nil
+ }
+
+ // Then we can create a new review thread comment on the review.
+ var addPullRequestReviewThreadMutation struct {
+ AddPullRequestReviewThread struct {
+ Thread struct {
+ ID githubv4.ID // We don't need this, but a selector is required or GQL complains.
+ }
+ } `graphql:"addPullRequestReviewThread(input: $input)"`
+ }
+
+ if err := client.Mutate(
+ ctx,
+ &addPullRequestReviewThreadMutation,
+ githubv4.AddPullRequestReviewThreadInput{
+ Path: githubv4.String(params.Path),
+ Body: githubv4.String(params.Body),
+ SubjectType: newGQLStringlikePtr[githubv4.PullRequestReviewThreadSubjectType](¶ms.SubjectType),
+ Line: newGQLIntPtr(params.Line),
+ Side: newGQLStringlikePtr[githubv4.DiffSide](params.Side),
+ StartLine: newGQLIntPtr(params.StartLine),
+ StartSide: newGQLStringlikePtr[githubv4.DiffSide](params.StartSide),
+ PullRequestReviewID: &review.ID,
+ },
+ nil,
+ ); err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+
+ // Return nothing interesting, just indicate success for the time being.
+ // In future, we may want to return the review ID, but for the moment, we're not leaking
+ // API implementation details to the LLM.
+ return mcp.NewToolResultText("pull request review comment successfully added to pending review"), nil
}
}
-// CreatePullRequestReview creates a tool to submit a review on a pull request.
-func CreatePullRequestReview(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
- return mcp.NewTool("create_pull_request_review",
- mcp.WithDescription(t("TOOL_CREATE_PULL_REQUEST_REVIEW_DESCRIPTION", "Create a review on a pull request")),
+// SubmitPendingPullRequestReview creates a tool to submit a pull request review.
+func SubmitPendingPullRequestReview(getGQLClient GetGQLClientFn, t translations.TranslationHelperFunc) (mcp.Tool, server.ToolHandlerFunc) {
+ return mcp.NewTool("submit_pending_pull_request_review",
+ mcp.WithDescription(t("TOOL_SUBMIT_PENDING_PULL_REQUEST_REVIEW_DESCRIPTION", "Submit the requester's latest pending pull request review, normally this is a final step after creating a pending review, adding comments first, unless you know that the user already did the first two steps, you should check before calling this.")),
+ mcp.WithToolAnnotation(mcp.ToolAnnotation{
+ Title: t("TOOL_SUBMIT_PENDING_PULL_REQUEST_REVIEW_USER_TITLE", "Submit 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
+ // add a new tool to get that ID for clients that aren't in the same context as the original pending review
+ // creation. So for now, we'll just accept the owner, repo and pull number and assume this is submitting
+ // the latest review from a user, since only one can be active at a time.
mcp.WithString("owner",
mcp.Required(),
mcp.Description("Repository owner"),
@@ -892,206 +1232,292 @@ func CreatePullRequestReview(getClient GetClientFn, t translations.TranslationHe
mcp.Required(),
mcp.Description("Pull request number"),
),
- mcp.WithString("body",
- mcp.Description("Review comment text"),
- ),
mcp.WithString("event",
mcp.Required(),
- mcp.Description("Review action to perform"),
+ mcp.Description("The event to perform"),
mcp.Enum("APPROVE", "REQUEST_CHANGES", "COMMENT"),
),
- mcp.WithString("commitId",
- mcp.Description("SHA of commit to review"),
- ),
- mcp.WithArray("comments",
- mcp.Items(
- map[string]interface{}{
- "type": "object",
- "additionalProperties": false,
- "required": []string{"path", "body", "position", "line", "side", "start_line", "start_side"},
- "properties": map[string]interface{}{
- "path": map[string]interface{}{
- "type": "string",
- "description": "path to the file",
- },
- "position": map[string]interface{}{
- "anyOf": []interface{}{
- map[string]string{"type": "number"},
- map[string]string{"type": "null"},
- },
- "description": "position of the comment in the diff",
- },
- "line": map[string]interface{}{
- "anyOf": []interface{}{
- map[string]string{"type": "number"},
- map[string]string{"type": "null"},
- },
- "description": "line number in the file to comment on. For multi-line comments, the end of the line range",
- },
- "side": map[string]interface{}{
- "anyOf": []interface{}{
- map[string]string{"type": "string"},
- map[string]string{"type": "null"},
- },
- "description": "The side of the diff on which the line resides. For multi-line comments, this is the side for the end of the line range. (LEFT or RIGHT)",
- },
- "start_line": map[string]interface{}{
- "anyOf": []interface{}{
- map[string]string{"type": "number"},
- map[string]string{"type": "null"},
- },
- "description": "The first line of the range to which the comment refers. Required for multi-line comments.",
- },
- "start_side": map[string]interface{}{
- "anyOf": []interface{}{
- map[string]string{"type": "string"},
- map[string]string{"type": "null"},
- },
- "description": "The side of the diff on which the start line resides for multi-line comments. (LEFT or RIGHT)",
- },
- "body": map[string]interface{}{
- "type": "string",
- "description": "comment body",
- },
- },
- },
- ),
- mcp.Description("Line-specific comments array of objects to place comments on pull request changes. Requires path and body. For line comments use line or position. For multi-line comments use start_line and line with optional side parameters."),
+ mcp.WithString("body",
+ mcp.Description("The text of the review comment"),
),
),
func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
- owner, err := requiredParam[string](request, "owner")
- if err != nil {
+ var params struct {
+ Owner string
+ Repo string
+ PullNumber int32
+ Event string
+ Body *string
+ }
+ if err := mapstructure.Decode(request.Params.Arguments, ¶ms); err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
- repo, err := requiredParam[string](request, "repo")
+
+ client, err := getGQLClient(ctx)
if err != nil {
- return mcp.NewToolResultError(err.Error()), nil
+ return nil, fmt.Errorf("failed to get GitHub GQL client: %w", err)
}
- pullNumber, err := RequiredInt(request, "pullNumber")
- if err != nil {
+
+ // First we'll get the current user
+ var getViewerQuery struct {
+ Viewer struct {
+ Login githubv4.String
+ }
+ }
+
+ if err := client.Query(ctx, &getViewerQuery, nil); err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
- event, err := requiredParam[string](request, "event")
- if err != nil {
+
+ var getLatestReviewForViewerQuery struct {
+ Repository struct {
+ PullRequest struct {
+ Reviews struct {
+ Nodes []struct {
+ ID githubv4.ID
+ State githubv4.PullRequestReviewState
+ URL githubv4.URI
+ }
+ } `graphql:"reviews(first: 1, author: $author)"`
+ } `graphql:"pullRequest(number: $prNum)"`
+ } `graphql:"repository(owner: $owner, name: $name)"`
+ }
+
+ vars := map[string]any{
+ "author": githubv4.String(getViewerQuery.Viewer.Login),
+ "owner": githubv4.String(params.Owner),
+ "name": githubv4.String(params.Repo),
+ "prNum": githubv4.Int(params.PullNumber),
+ }
+
+ if err := client.Query(context.Background(), &getLatestReviewForViewerQuery, vars); err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
- // Create review request
- reviewRequest := &github.PullRequestReviewRequest{
- Event: github.Ptr(event),
+ // Validate there is one review and the state is pending
+ if len(getLatestReviewForViewerQuery.Repository.PullRequest.Reviews.Nodes) == 0 {
+ return mcp.NewToolResultError("No pending review found for the viewer"), nil
}
- // Add body if provided
- body, err := OptionalParam[string](request, "body")
- if err != nil {
+ review := getLatestReviewForViewerQuery.Repository.PullRequest.Reviews.Nodes[0]
+ if review.State != githubv4.PullRequestReviewStatePending {
+ errText := fmt.Sprintf("The latest review, found at %s is not pending", review.URL)
+ return mcp.NewToolResultError(errText), nil
+ }
+
+ // Prepare the mutation
+ var submitPullRequestReviewMutation struct {
+ SubmitPullRequestReview struct {
+ PullRequestReview struct {
+ ID githubv4.ID // We don't need this, but a selector is required or GQL complains.
+ }
+ } `graphql:"submitPullRequestReview(input: $input)"`
+ }
+
+ if err := client.Mutate(
+ ctx,
+ &submitPullRequestReviewMutation,
+ githubv4.SubmitPullRequestReviewInput{
+ PullRequestReviewID: &review.ID,
+ Event: githubv4.PullRequestReviewEvent(params.Event),
+ Body: newGQLStringlikePtr[githubv4.String](params.Body),
+ },
+ nil,
+ ); err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
- if body != "" {
- reviewRequest.Body = github.Ptr(body)
+
+ // Return nothing interesting, just indicate success for the time being.
+ // In future, we may want to return the review ID, but for the moment, we're not leaking
+ // API implementation details to the LLM.
+ return mcp.NewToolResultText("pending pull request review successfully submitted"), nil
+ }
+}
+
+func DeletePendingPullRequestReview(getGQLClient GetGQLClientFn, t translations.TranslationHelperFunc) (mcp.Tool, server.ToolHandlerFunc) {
+ return mcp.NewTool("delete_pending_pull_request_review",
+ mcp.WithDescription(t("TOOL_DELETE_PENDING_PULL_REQUEST_REVIEW_DESCRIPTION", "Delete the requester's latest pending pull request review. Use this after the user decides not to submit a pending review, if you don't know if they already created one then check first.")),
+ mcp.WithToolAnnotation(mcp.ToolAnnotation{
+ Title: t("TOOL_DELETE_PENDING_PULL_REQUEST_REVIEW_USER_TITLE", "Delete 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
+ // add a new tool to get that ID for clients that aren't in the same context as the original pending review
+ // creation. So for now, we'll just accept the owner, repo and pull number and assume this is deleting
+ // the latest pending review from a user, since only one can be active at a time.
+ mcp.WithString("owner",
+ mcp.Required(),
+ mcp.Description("Repository owner"),
+ ),
+ mcp.WithString("repo",
+ mcp.Required(),
+ mcp.Description("Repository name"),
+ ),
+ mcp.WithNumber("pullNumber",
+ mcp.Required(),
+ mcp.Description("Pull request number"),
+ ),
+ ),
+ func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
+ var params struct {
+ Owner string
+ Repo string
+ PullNumber int32
+ }
+ if err := mapstructure.Decode(request.Params.Arguments, ¶ms); err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
}
- // Add commit ID if provided
- commitID, err := OptionalParam[string](request, "commitId")
+ client, err := getGQLClient(ctx)
if err != nil {
- return mcp.NewToolResultError(err.Error()), nil
+ return nil, fmt.Errorf("failed to get GitHub GQL client: %w", err)
}
- if commitID != "" {
- reviewRequest.CommitID = github.Ptr(commitID)
+
+ // First we'll get the current user
+ var getViewerQuery struct {
+ Viewer struct {
+ Login githubv4.String
+ }
}
- // Add comments if provided
- if commentsObj, ok := request.Params.Arguments["comments"].([]interface{}); ok && len(commentsObj) > 0 {
- comments := []*github.DraftReviewComment{}
+ if err := client.Query(ctx, &getViewerQuery, nil); err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
- for _, c := range commentsObj {
- commentMap, ok := c.(map[string]interface{})
- if !ok {
- return mcp.NewToolResultError("each comment must be an object with path and body"), nil
- }
+ var getLatestReviewForViewerQuery struct {
+ Repository struct {
+ PullRequest struct {
+ Reviews struct {
+ Nodes []struct {
+ ID githubv4.ID
+ State githubv4.PullRequestReviewState
+ URL githubv4.URI
+ }
+ } `graphql:"reviews(first: 1, author: $author)"`
+ } `graphql:"pullRequest(number: $prNum)"`
+ } `graphql:"repository(owner: $owner, name: $name)"`
+ }
- path, ok := commentMap["path"].(string)
- if !ok || path == "" {
- return mcp.NewToolResultError("each comment must have a path"), nil
- }
+ vars := map[string]any{
+ "author": githubv4.String(getViewerQuery.Viewer.Login),
+ "owner": githubv4.String(params.Owner),
+ "name": githubv4.String(params.Repo),
+ "prNum": githubv4.Int(params.PullNumber),
+ }
- body, ok := commentMap["body"].(string)
- if !ok || body == "" {
- return mcp.NewToolResultError("each comment must have a body"), nil
- }
+ if err := client.Query(context.Background(), &getLatestReviewForViewerQuery, vars); err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
- _, hasPosition := commentMap["position"].(float64)
- _, hasLine := commentMap["line"].(float64)
- _, hasSide := commentMap["side"].(string)
- _, hasStartLine := commentMap["start_line"].(float64)
- _, hasStartSide := commentMap["start_side"].(string)
-
- switch {
- case !hasPosition && !hasLine:
- return mcp.NewToolResultError("each comment must have either position or line"), nil
- case hasPosition && (hasLine || hasSide || hasStartLine || hasStartSide):
- return mcp.NewToolResultError("position cannot be combined with line, side, start_line, or start_side"), nil
- case hasStartSide && !hasSide:
- return mcp.NewToolResultError("if start_side is provided, side must also be provided"), nil
- }
+ // Validate there is one review and the state is pending
+ if len(getLatestReviewForViewerQuery.Repository.PullRequest.Reviews.Nodes) == 0 {
+ return mcp.NewToolResultError("No pending review found for the viewer"), nil
+ }
- comment := &github.DraftReviewComment{
- Path: github.Ptr(path),
- Body: github.Ptr(body),
- }
+ review := getLatestReviewForViewerQuery.Repository.PullRequest.Reviews.Nodes[0]
+ if review.State != githubv4.PullRequestReviewStatePending {
+ errText := fmt.Sprintf("The latest review, found at %s is not pending", review.URL)
+ return mcp.NewToolResultError(errText), nil
+ }
- if positionFloat, ok := commentMap["position"].(float64); ok {
- comment.Position = github.Ptr(int(positionFloat))
- } else if lineFloat, ok := commentMap["line"].(float64); ok {
- comment.Line = github.Ptr(int(lineFloat))
- }
- if side, ok := commentMap["side"].(string); ok {
- comment.Side = github.Ptr(side)
- }
- if startLineFloat, ok := commentMap["start_line"].(float64); ok {
- comment.StartLine = github.Ptr(int(startLineFloat))
- }
- if startSide, ok := commentMap["start_side"].(string); ok {
- comment.StartSide = github.Ptr(startSide)
+ // Prepare the mutation
+ var deletePullRequestReviewMutation struct {
+ DeletePullRequestReview struct {
+ PullRequestReview struct {
+ ID githubv4.ID // We don't need this, but a selector is required or GQL complains.
}
+ } `graphql:"deletePullRequestReview(input: $input)"`
+ }
- comments = append(comments, comment)
- }
+ if err := client.Mutate(
+ ctx,
+ &deletePullRequestReviewMutation,
+ githubv4.DeletePullRequestReviewInput{
+ PullRequestReviewID: &review.ID,
+ },
+ nil,
+ ); err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
- reviewRequest.Comments = comments
+ // Return nothing interesting, just indicate success for the time being.
+ // In future, we may want to return the review ID, but for the moment, we're not leaking
+ // API implementation details to the LLM.
+ return mcp.NewToolResultText("pending pull request review successfully deleted"), nil
+ }
+}
+
+func GetPullRequestDiff(getClient GetClientFn, t translations.TranslationHelperFunc) (mcp.Tool, server.ToolHandlerFunc) {
+ return mcp.NewTool("get_pull_request_diff",
+ mcp.WithDescription(t("TOOL_GET_PULL_REQUEST_DIFF_DESCRIPTION", "Get the diff of a pull request.")),
+ mcp.WithToolAnnotation(mcp.ToolAnnotation{
+ Title: t("TOOL_GET_PULL_REQUEST_DIFF_USER_TITLE", "Get pull request diff"),
+ ReadOnlyHint: ToBoolPtr(true),
+ }),
+ mcp.WithString("owner",
+ mcp.Required(),
+ mcp.Description("Repository owner"),
+ ),
+ mcp.WithString("repo",
+ mcp.Required(),
+ mcp.Description("Repository name"),
+ ),
+ mcp.WithNumber("pullNumber",
+ mcp.Required(),
+ mcp.Description("Pull request number"),
+ ),
+ ),
+ func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
+ var params struct {
+ Owner string
+ Repo string
+ PullNumber int32
+ }
+ if err := mapstructure.Decode(request.Params.Arguments, ¶ms); 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)
+ return mcp.NewToolResultError(fmt.Sprintf("failed to get GitHub client: %v", err)), nil
}
- review, resp, err := client.PullRequests.CreateReview(ctx, owner, repo, pullNumber, reviewRequest)
+
+ raw, resp, err := client.PullRequests.GetRaw(
+ ctx,
+ params.Owner,
+ params.Repo,
+ int(params.PullNumber),
+ github.RawOptions{Type: github.Diff},
+ )
if err != nil {
- return nil, fmt.Errorf("failed to create pull request review: %w", err)
+ return mcp.NewToolResultError(err.Error()), 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 create pull request review: %s", string(body))), nil
+ return mcp.NewToolResultError(fmt.Sprintf("failed to get pull request diff: %s", string(body))), nil
}
- r, err := json.Marshal(review)
- if err != nil {
- return nil, fmt.Errorf("failed to marshal response: %w", err)
- }
+ defer func() { _ = resp.Body.Close() }()
- return mcp.NewToolResultText(string(r)), nil
+ // Return the raw response
+ return mcp.NewToolResultText(string(raw)), nil
}
}
-// CreatePullRequest creates a tool to create a new pull request.
-func CreatePullRequest(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
- return mcp.NewTool("create_pull_request",
- mcp.WithDescription(t("TOOL_CREATE_PULL_REQUEST_DESCRIPTION", "Create a new pull request in a GitHub repository")),
+// RequestCopilotReview creates a tool to request a Copilot review for a pull request.
+// Note that this tool will not work on GHES where this feature is unsupported. In future, we should not expose this
+// tool if the configured host does not support it.
+func RequestCopilotReview(getClient GetClientFn, t translations.TranslationHelperFunc) (mcp.Tool, server.ToolHandlerFunc) {
+ return mcp.NewTool("request_copilot_review",
+ mcp.WithDescription(t("TOOL_REQUEST_COPILOT_REVIEW_DESCRIPTION", "Request a GitHub Copilot code review for a pull request. Use this for automated feedback on pull requests, usually before requesting a human reviewer.")),
+ mcp.WithToolAnnotation(mcp.ToolAnnotation{
+ Title: t("TOOL_REQUEST_COPILOT_REVIEW_USER_TITLE", "Request Copilot review"),
+ ReadOnlyHint: ToBoolPtr(false),
+ }),
mcp.WithString("owner",
mcp.Required(),
mcp.Description("Repository owner"),
@@ -1100,85 +1526,44 @@ func CreatePullRequest(getClient GetClientFn, t translations.TranslationHelperFu
mcp.Required(),
mcp.Description("Repository name"),
),
- mcp.WithString("title",
- mcp.Required(),
- mcp.Description("PR title"),
- ),
- mcp.WithString("body",
- mcp.Description("PR description"),
- ),
- mcp.WithString("head",
- mcp.Required(),
- mcp.Description("Branch containing changes"),
- ),
- mcp.WithString("base",
+ mcp.WithNumber("pullNumber",
mcp.Required(),
- mcp.Description("Branch to merge into"),
- ),
- mcp.WithBoolean("draft",
- mcp.Description("Create as draft PR"),
- ),
- mcp.WithBoolean("maintainer_can_modify",
- mcp.Description("Allow maintainer edits"),
+ mcp.Description("Pull request 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
- }
- title, err := requiredParam[string](request, "title")
- if err != nil {
- return mcp.NewToolResultError(err.Error()), nil
- }
- head, err := requiredParam[string](request, "head")
- if err != nil {
- return mcp.NewToolResultError(err.Error()), nil
- }
- base, err := requiredParam[string](request, "base")
+ owner, err := RequiredParam[string](request, "owner")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
- body, err := OptionalParam[string](request, "body")
+ repo, err := RequiredParam[string](request, "repo")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
- draft, err := OptionalParam[bool](request, "draft")
+ pullNumber, err := RequiredInt(request, "pullNumber")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
- maintainerCanModify, err := OptionalParam[bool](request, "maintainer_can_modify")
+ client, err := getClient(ctx)
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
- newPR := &github.NewPullRequest{
- Title: github.Ptr(title),
- Head: github.Ptr(head),
- Base: github.Ptr(base),
- }
-
- if body != "" {
- newPR.Body = github.Ptr(body)
- }
-
- newPR.Draft = github.Ptr(draft)
- newPR.MaintainerCanModify = github.Ptr(maintainerCanModify)
-
- client, err := getClient(ctx)
+ _, resp, err := client.PullRequests.RequestReviewers(
+ ctx,
+ owner,
+ repo,
+ pullNumber,
+ github.ReviewersRequest{
+ // The login name of the copilot reviewer bot
+ Reviewers: []string{"copilot-pull-request-reviewer[bot]"},
+ },
+ )
if err != nil {
- return nil, fmt.Errorf("failed to get GitHub client: %w", err)
- }
- pr, resp, err := client.PullRequests.Create(ctx, owner, repo, newPR)
- if err != nil {
- return nil, fmt.Errorf("failed to create pull request: %w", err)
+ return nil, fmt.Errorf("failed to request copilot review: %w", err)
}
defer func() { _ = resp.Body.Close() }()
@@ -1187,14 +1572,38 @@ func CreatePullRequest(getClient GetClientFn, t translations.TranslationHelperFu
if err != nil {
return nil, fmt.Errorf("failed to read response body: %w", err)
}
- return mcp.NewToolResultError(fmt.Sprintf("failed to create pull request: %s", string(body))), nil
- }
-
- r, err := json.Marshal(pr)
- if err != nil {
- return nil, fmt.Errorf("failed to marshal response: %w", err)
+ return mcp.NewToolResultError(fmt.Sprintf("failed to request copilot review: %s", string(body))), nil
}
- return mcp.NewToolResultText(string(r)), nil
+ // Return nothing on success, as there's not much value in returning the Pull Request itself
+ return mcp.NewToolResultText(""), nil
}
}
+
+// newGQLString like takes something that approximates a string (of which there are many types in shurcooL/githubv4)
+// and constructs a pointer to it, or nil if the string is empty. This is extremely useful because when we parse
+// params from the MCP request, we need to convert them to types that are pointers of type def strings and it's
+// not possible to take a pointer of an anonymous value e.g. &githubv4.String("foo").
+func newGQLStringlike[T ~string](s string) *T {
+ if s == "" {
+ return nil
+ }
+ stringlike := T(s)
+ return &stringlike
+}
+
+func newGQLStringlikePtr[T ~string](s *string) *T {
+ if s == nil {
+ return nil
+ }
+ stringlike := T(*s)
+ return &stringlike
+}
+
+func newGQLIntPtr(i *int32) *githubv4.Int {
+ if i == nil {
+ return nil
+ }
+ gi := githubv4.Int(*i)
+ return &gi
+}
diff --git a/pkg/github/pullrequests_test.go b/pkg/github/pullrequests_test.go
index bb3726249..cdbccc283 100644
--- a/pkg/github/pullrequests_test.go
+++ b/pkg/github/pullrequests_test.go
@@ -7,8 +7,11 @@ import (
"testing"
"time"
+ "github.com/github/github-mcp-server/internal/githubv4mock"
"github.com/github/github-mcp-server/pkg/translations"
- "github.com/google/go-github/v69/github"
+ "github.com/google/go-github/v72/github"
+ "github.com/shurcooL/githubv4"
+
"github.com/migueleliasweb/go-github-mock/src/mock"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -1192,377 +1195,6 @@ func Test_GetPullRequestReviews(t *testing.T) {
}
}
-func Test_CreatePullRequestReview(t *testing.T) {
- // Verify tool definition once
- mockClient := github.NewClient(nil)
- tool, _ := CreatePullRequestReview(stubGetClientFn(mockClient), translations.NullTranslationHelper)
-
- assert.Equal(t, "create_pull_request_review", 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, "pullNumber")
- assert.Contains(t, tool.InputSchema.Properties, "body")
- assert.Contains(t, tool.InputSchema.Properties, "event")
- assert.Contains(t, tool.InputSchema.Properties, "commitId")
- assert.Contains(t, tool.InputSchema.Properties, "comments")
- assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "pullNumber", "event"})
-
- // Setup mock review for success case
- mockReview := &github.PullRequestReview{
- ID: github.Ptr(int64(301)),
- State: github.Ptr("APPROVED"),
- Body: github.Ptr("Looks good!"),
- HTMLURL: github.Ptr("https://github.com/owner/repo/pull/42#pullrequestreview-301"),
- User: &github.User{
- Login: github.Ptr("reviewer"),
- },
- CommitID: github.Ptr("abcdef123456"),
- SubmittedAt: &github.Timestamp{Time: time.Now()},
- }
-
- tests := []struct {
- name string
- mockedClient *http.Client
- requestArgs map[string]interface{}
- expectError bool
- expectedReview *github.PullRequestReview
- expectedErrMsg string
- }{
- {
- name: "successful review creation with body only",
- mockedClient: mock.NewMockedHTTPClient(
- mock.WithRequestMatchHandler(
- mock.PostReposPullsReviewsByOwnerByRepoByPullNumber,
- expectRequestBody(t, map[string]interface{}{
- "body": "Looks good!",
- "event": "APPROVE",
- }).andThen(
- mockResponse(t, http.StatusOK, mockReview),
- ),
- ),
- ),
- requestArgs: map[string]interface{}{
- "owner": "owner",
- "repo": "repo",
- "pullNumber": float64(42),
- "body": "Looks good!",
- "event": "APPROVE",
- },
- expectError: false,
- expectedReview: mockReview,
- },
- {
- name: "successful review creation with commitId",
- mockedClient: mock.NewMockedHTTPClient(
- mock.WithRequestMatchHandler(
- mock.PostReposPullsReviewsByOwnerByRepoByPullNumber,
- expectRequestBody(t, map[string]interface{}{
- "body": "Looks good!",
- "event": "APPROVE",
- "commit_id": "abcdef123456",
- }).andThen(
- mockResponse(t, http.StatusOK, mockReview),
- ),
- ),
- ),
- requestArgs: map[string]interface{}{
- "owner": "owner",
- "repo": "repo",
- "pullNumber": float64(42),
- "body": "Looks good!",
- "event": "APPROVE",
- "commitId": "abcdef123456",
- },
- expectError: false,
- expectedReview: mockReview,
- },
- {
- name: "successful review creation with comments",
- mockedClient: mock.NewMockedHTTPClient(
- mock.WithRequestMatchHandler(
- mock.PostReposPullsReviewsByOwnerByRepoByPullNumber,
- expectRequestBody(t, map[string]interface{}{
- "body": "Some issues to fix",
- "event": "REQUEST_CHANGES",
- "comments": []interface{}{
- map[string]interface{}{
- "path": "file1.go",
- "position": float64(10),
- "body": "This needs to be fixed",
- },
- map[string]interface{}{
- "path": "file2.go",
- "position": float64(20),
- "body": "Consider a different approach here",
- },
- },
- }).andThen(
- mockResponse(t, http.StatusOK, mockReview),
- ),
- ),
- ),
- requestArgs: map[string]interface{}{
- "owner": "owner",
- "repo": "repo",
- "pullNumber": float64(42),
- "body": "Some issues to fix",
- "event": "REQUEST_CHANGES",
- "comments": []interface{}{
- map[string]interface{}{
- "path": "file1.go",
- "position": float64(10),
- "body": "This needs to be fixed",
- },
- map[string]interface{}{
- "path": "file2.go",
- "position": float64(20),
- "body": "Consider a different approach here",
- },
- },
- },
- expectError: false,
- expectedReview: mockReview,
- },
- {
- name: "invalid comment format",
- mockedClient: mock.NewMockedHTTPClient(
- mock.WithRequestMatchHandler(
- mock.PostReposPullsReviewsByOwnerByRepoByPullNumber,
- http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
- w.WriteHeader(http.StatusUnprocessableEntity)
- _, _ = w.Write([]byte(`{"message": "Invalid comment format"}`))
- }),
- ),
- ),
- requestArgs: map[string]interface{}{
- "owner": "owner",
- "repo": "repo",
- "pullNumber": float64(42),
- "event": "REQUEST_CHANGES",
- "comments": []interface{}{
- map[string]interface{}{
- "path": "file1.go",
- // missing position
- "body": "This needs to be fixed",
- },
- },
- },
- expectError: false,
- expectedErrMsg: "each comment must have either position or line",
- },
- {
- name: "successful review creation with line parameter",
- mockedClient: mock.NewMockedHTTPClient(
- mock.WithRequestMatchHandler(
- mock.PostReposPullsReviewsByOwnerByRepoByPullNumber,
- expectRequestBody(t, map[string]interface{}{
- "body": "Code review comments",
- "event": "COMMENT",
- "comments": []interface{}{
- map[string]interface{}{
- "path": "main.go",
- "line": float64(42),
- "body": "Consider adding a comment here",
- },
- },
- }).andThen(
- mockResponse(t, http.StatusOK, mockReview),
- ),
- ),
- ),
- requestArgs: map[string]interface{}{
- "owner": "owner",
- "repo": "repo",
- "pullNumber": float64(42),
- "body": "Code review comments",
- "event": "COMMENT",
- "comments": []interface{}{
- map[string]interface{}{
- "path": "main.go",
- "line": float64(42),
- "body": "Consider adding a comment here",
- },
- },
- },
- expectError: false,
- expectedReview: mockReview,
- },
- {
- name: "successful review creation with multi-line comment",
- mockedClient: mock.NewMockedHTTPClient(
- mock.WithRequestMatchHandler(
- mock.PostReposPullsReviewsByOwnerByRepoByPullNumber,
- expectRequestBody(t, map[string]interface{}{
- "body": "Multi-line comment review",
- "event": "COMMENT",
- "comments": []interface{}{
- map[string]interface{}{
- "path": "main.go",
- "start_line": float64(10),
- "line": float64(15),
- "side": "RIGHT",
- "body": "This entire block needs refactoring",
- },
- },
- }).andThen(
- mockResponse(t, http.StatusOK, mockReview),
- ),
- ),
- ),
- requestArgs: map[string]interface{}{
- "owner": "owner",
- "repo": "repo",
- "pullNumber": float64(42),
- "body": "Multi-line comment review",
- "event": "COMMENT",
- "comments": []interface{}{
- map[string]interface{}{
- "path": "main.go",
- "start_line": float64(10),
- "line": float64(15),
- "side": "RIGHT",
- "body": "This entire block needs refactoring",
- },
- },
- },
- expectError: false,
- expectedReview: mockReview,
- },
- {
- name: "invalid multi-line comment - missing line parameter",
- mockedClient: mock.NewMockedHTTPClient(),
- requestArgs: map[string]interface{}{
- "owner": "owner",
- "repo": "repo",
- "pullNumber": float64(42),
- "event": "COMMENT",
- "comments": []interface{}{
- map[string]interface{}{
- "path": "main.go",
- "start_line": float64(10),
- // missing line parameter
- "body": "Invalid multi-line comment",
- },
- },
- },
- expectError: false,
- expectedErrMsg: "each comment must have either position or line", // Updated error message
- },
- {
- name: "invalid comment - mixing position with line parameters",
- mockedClient: mock.NewMockedHTTPClient(
- mock.WithRequestMatch(
- mock.PostReposPullsReviewsByOwnerByRepoByPullNumber,
- mockReview,
- ),
- ),
- requestArgs: map[string]interface{}{
- "owner": "owner",
- "repo": "repo",
- "pullNumber": float64(42),
- "event": "COMMENT",
- "comments": []interface{}{
- map[string]interface{}{
- "path": "main.go",
- "position": float64(5),
- "line": float64(42),
- "body": "Invalid parameter combination",
- },
- },
- },
- expectError: false,
- expectedErrMsg: "position cannot be combined with line, side, start_line, or start_side",
- },
- {
- name: "invalid multi-line comment - missing side parameter",
- mockedClient: mock.NewMockedHTTPClient(),
- requestArgs: map[string]interface{}{
- "owner": "owner",
- "repo": "repo",
- "pullNumber": float64(42),
- "event": "COMMENT",
- "comments": []interface{}{
- map[string]interface{}{
- "path": "main.go",
- "start_line": float64(10),
- "line": float64(15),
- "start_side": "LEFT",
- // missing side parameter
- "body": "Invalid multi-line comment",
- },
- },
- },
- expectError: false,
- expectedErrMsg: "if start_side is provided, side must also be provided",
- },
- {
- name: "review creation fails",
- mockedClient: mock.NewMockedHTTPClient(
- mock.WithRequestMatchHandler(
- mock.PostReposPullsReviewsByOwnerByRepoByPullNumber,
- http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
- w.WriteHeader(http.StatusUnprocessableEntity)
- _, _ = w.Write([]byte(`{"message": "Invalid comment format"}`))
- }),
- ),
- ),
- requestArgs: map[string]interface{}{
- "owner": "owner",
- "repo": "repo",
- "pullNumber": float64(42),
- "body": "Looks good!",
- "event": "APPROVE",
- },
- expectError: true,
- expectedErrMsg: "failed to create pull request review",
- },
- }
-
- for _, tc := range tests {
- t.Run(tc.name, func(t *testing.T) {
- // Setup client with mock
- client := github.NewClient(tc.mockedClient)
- _, handler := CreatePullRequestReview(stubGetClientFn(client), translations.NullTranslationHelper)
-
- // Create call request
- request := createMCPRequest(tc.requestArgs)
-
- // Call handler
- result, err := handler(context.Background(), request)
-
- // Verify results
- if tc.expectError {
- require.Error(t, err)
- assert.Contains(t, err.Error(), tc.expectedErrMsg)
- return
- }
-
- require.NoError(t, err)
-
- // For error messages in the result
- if tc.expectedErrMsg != "" {
- textContent := getTextResult(t, result)
- assert.Contains(t, textContent.Text, tc.expectedErrMsg)
- return
- }
-
- // Parse the result and get the text content if no error
- textContent := getTextResult(t, result)
-
- // Unmarshal and verify the result
- var returnedReview github.PullRequestReview
- err = json.Unmarshal([]byte(textContent.Text), &returnedReview)
- require.NoError(t, err)
- assert.Equal(t, *tc.expectedReview.ID, *returnedReview.ID)
- assert.Equal(t, *tc.expectedReview.State, *returnedReview.State)
- assert.Equal(t, *tc.expectedReview.Body, *returnedReview.Body)
- assert.Equal(t, *tc.expectedReview.User.Login, *returnedReview.User.Login)
- assert.Equal(t, *tc.expectedReview.HTMLURL, *returnedReview.HTMLURL)
- })
- }
-}
-
func Test_CreatePullRequest(t *testing.T) {
// Verify tool definition once
mockClient := github.NewClient(nil)
@@ -1720,161 +1352,288 @@ func Test_CreatePullRequest(t *testing.T) {
}
}
-func Test_AddPullRequestReviewComment(t *testing.T) {
- mockClient := github.NewClient(nil)
- tool, _ := AddPullRequestReviewComment(stubGetClientFn(mockClient), translations.NullTranslationHelper)
+func TestCreateAndSubmitPullRequestReview(t *testing.T) {
+ t.Parallel()
+
+ // Verify tool definition once
+ mockClient := githubv4.NewClient(nil)
+ tool, _ := CreateAndSubmitPullRequestReview(stubGetGQLClientFn(mockClient), translations.NullTranslationHelper)
- assert.Equal(t, "add_pull_request_review_comment", tool.Name)
+ assert.Equal(t, "create_and_submit_pull_request_review", 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, "pull_number")
+ assert.Contains(t, tool.InputSchema.Properties, "pullNumber")
assert.Contains(t, tool.InputSchema.Properties, "body")
- assert.Contains(t, tool.InputSchema.Properties, "commit_id")
- assert.Contains(t, tool.InputSchema.Properties, "path")
- // Since we've updated commit_id and path to be optional when using in_reply_to
- assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "pull_number", "body"})
-
- mockComment := &github.PullRequestComment{
- ID: github.Ptr(int64(123)),
- Body: github.Ptr("Great stuff!"),
- Path: github.Ptr("file1.txt"),
- Line: github.Ptr(2),
- Side: github.Ptr("RIGHT"),
- }
-
- mockReply := &github.PullRequestComment{
- ID: github.Ptr(int64(456)),
- Body: github.Ptr("Good point, will fix!"),
- }
+ assert.Contains(t, tool.InputSchema.Properties, "event")
+ assert.Contains(t, tool.InputSchema.Properties, "commitID")
+ assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "pullNumber", "body", "event"})
tests := []struct {
- name string
- mockedClient *http.Client
- requestArgs map[string]interface{}
- expectError bool
- expectedComment *github.PullRequestComment
- expectedErrMsg string
+ name string
+ mockedClient *http.Client
+ requestArgs map[string]any
+ expectToolError bool
+ expectedToolErrMsg string
}{
{
- name: "successful line comment creation",
- mockedClient: mock.NewMockedHTTPClient(
- mock.WithRequestMatchHandler(
- mock.PostReposPullsCommentsByOwnerByRepoByPullNumber,
- http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
- w.WriteHeader(http.StatusCreated)
- err := json.NewEncoder(w).Encode(mockComment)
- if err != nil {
- http.Error(w, err.Error(), http.StatusInternalServerError)
- return
- }
- }),
+ name: "successful review creation",
+ mockedClient: githubv4mock.NewMockedHTTPClient(
+ githubv4mock.NewQueryMatcher(
+ struct {
+ Repository struct {
+ PullRequest struct {
+ ID githubv4.ID
+ } `graphql:"pullRequest(number: $prNum)"`
+ } `graphql:"repository(owner: $owner, name: $repo)"`
+ }{},
+ map[string]any{
+ "owner": githubv4.String("owner"),
+ "repo": githubv4.String("repo"),
+ "prNum": githubv4.Int(42),
+ },
+ githubv4mock.DataResponse(
+ map[string]any{
+ "repository": map[string]any{
+ "pullRequest": map[string]any{
+ "id": "PR_kwDODKw3uc6WYN1T",
+ },
+ },
+ },
+ ),
+ ),
+ githubv4mock.NewMutationMatcher(
+ struct {
+ AddPullRequestReview struct {
+ PullRequestReview struct {
+ ID githubv4.ID
+ }
+ } `graphql:"addPullRequestReview(input: $input)"`
+ }{},
+ githubv4.AddPullRequestReviewInput{
+ PullRequestID: githubv4.ID("PR_kwDODKw3uc6WYN1T"),
+ Body: githubv4.NewString("This is a test review"),
+ Event: githubv4mock.Ptr(githubv4.PullRequestReviewEventComment),
+ CommitOID: githubv4.NewGitObjectID("abcd1234"),
+ },
+ nil,
+ githubv4mock.DataResponse(map[string]any{}),
),
),
- requestArgs: map[string]interface{}{
- "owner": "owner",
- "repo": "repo",
- "pull_number": float64(1),
- "body": "Great stuff!",
- "commit_id": "6dcb09b5b57875f334f61aebed695e2e4193db5e",
- "path": "file1.txt",
- "line": float64(2),
- "side": "RIGHT",
+ requestArgs: map[string]any{
+ "owner": "owner",
+ "repo": "repo",
+ "pullNumber": float64(42),
+ "body": "This is a test review",
+ "event": "COMMENT",
+ "commitID": "abcd1234",
},
- expectError: false,
- expectedComment: mockComment,
+ expectToolError: false,
},
{
- name: "successful reply using in_reply_to",
- mockedClient: mock.NewMockedHTTPClient(
- mock.WithRequestMatchHandler(
- mock.PostReposPullsCommentsByOwnerByRepoByPullNumber,
- http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
- w.WriteHeader(http.StatusCreated)
- err := json.NewEncoder(w).Encode(mockReply)
- if err != nil {
- http.Error(w, err.Error(), http.StatusInternalServerError)
- return
- }
- }),
+ name: "failure to get pull request",
+ mockedClient: githubv4mock.NewMockedHTTPClient(
+ githubv4mock.NewQueryMatcher(
+ struct {
+ Repository struct {
+ PullRequest struct {
+ ID githubv4.ID
+ } `graphql:"pullRequest(number: $prNum)"`
+ } `graphql:"repository(owner: $owner, name: $repo)"`
+ }{},
+ map[string]any{
+ "owner": githubv4.String("owner"),
+ "repo": githubv4.String("repo"),
+ "prNum": githubv4.Int(42),
+ },
+ githubv4mock.ErrorResponse("expected test failure"),
),
),
- requestArgs: map[string]interface{}{
- "owner": "owner",
- "repo": "repo",
- "pull_number": float64(1),
- "body": "Good point, will fix!",
- "in_reply_to": float64(123),
+ requestArgs: map[string]any{
+ "owner": "owner",
+ "repo": "repo",
+ "pullNumber": float64(42),
+ "body": "This is a test review",
+ "event": "COMMENT",
+ "commitID": "abcd1234",
},
- expectError: false,
- expectedComment: mockReply,
+ expectToolError: true,
+ expectedToolErrMsg: "expected test failure",
},
{
- name: "comment creation fails",
+ name: "failure to submit review",
+ mockedClient: githubv4mock.NewMockedHTTPClient(
+ githubv4mock.NewQueryMatcher(
+ struct {
+ Repository struct {
+ PullRequest struct {
+ ID githubv4.ID
+ } `graphql:"pullRequest(number: $prNum)"`
+ } `graphql:"repository(owner: $owner, name: $repo)"`
+ }{},
+ map[string]any{
+ "owner": githubv4.String("owner"),
+ "repo": githubv4.String("repo"),
+ "prNum": githubv4.Int(42),
+ },
+ githubv4mock.DataResponse(
+ map[string]any{
+ "repository": map[string]any{
+ "pullRequest": map[string]any{
+ "id": "PR_kwDODKw3uc6WYN1T",
+ },
+ },
+ },
+ ),
+ ),
+ githubv4mock.NewMutationMatcher(
+ struct {
+ AddPullRequestReview struct {
+ PullRequestReview struct {
+ ID githubv4.ID
+ }
+ } `graphql:"addPullRequestReview(input: $input)"`
+ }{},
+ githubv4.AddPullRequestReviewInput{
+ PullRequestID: githubv4.ID("PR_kwDODKw3uc6WYN1T"),
+ Body: githubv4.NewString("This is a test review"),
+ Event: githubv4mock.Ptr(githubv4.PullRequestReviewEventComment),
+ CommitOID: githubv4.NewGitObjectID("abcd1234"),
+ },
+ nil,
+ githubv4mock.ErrorResponse("expected test failure"),
+ ),
+ ),
+ requestArgs: map[string]any{
+ "owner": "owner",
+ "repo": "repo",
+ "pullNumber": float64(42),
+ "body": "This is a test review",
+ "event": "COMMENT",
+ "commitID": "abcd1234",
+ },
+ expectToolError: true,
+ expectedToolErrMsg: "expected test failure",
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
+
+ // Setup client with mock
+ client := githubv4.NewClient(tc.mockedClient)
+ _, handler := CreateAndSubmitPullRequestReview(stubGetGQLClientFn(client), translations.NullTranslationHelper)
+
+ // Create call request
+ request := createMCPRequest(tc.requestArgs)
+
+ // Call handler
+ result, err := handler(context.Background(), request)
+ require.NoError(t, err)
+
+ textContent := getTextResult(t, result)
+
+ if tc.expectToolError {
+ require.True(t, result.IsError)
+ assert.Contains(t, textContent.Text, tc.expectedToolErrMsg)
+ return
+ }
+
+ // Parse the result and get the text content if no error
+ require.Equal(t, textContent.Text, "pull request review submitted successfully")
+ })
+ }
+}
+
+func Test_RequestCopilotReview(t *testing.T) {
+ t.Parallel()
+
+ mockClient := github.NewClient(nil)
+ tool, _ := RequestCopilotReview(stubGetClientFn(mockClient), translations.NullTranslationHelper)
+
+ assert.Equal(t, "request_copilot_review", 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, "pullNumber")
+ assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "pullNumber"})
+
+ // Setup mock PR for success case
+ mockPR := &github.PullRequest{
+ Number: github.Ptr(42),
+ Title: github.Ptr("Test PR"),
+ State: github.Ptr("open"),
+ HTMLURL: github.Ptr("https://github.com/owner/repo/pull/42"),
+ Head: &github.PullRequestBranch{
+ SHA: github.Ptr("abcd1234"),
+ Ref: github.Ptr("feature-branch"),
+ },
+ Base: &github.PullRequestBranch{
+ Ref: github.Ptr("main"),
+ },
+ Body: github.Ptr("This is a test PR"),
+ User: &github.User{
+ Login: github.Ptr("testuser"),
+ },
+ }
+
+ tests := []struct {
+ name string
+ mockedClient *http.Client
+ requestArgs map[string]any
+ expectError bool
+ expectedErrMsg string
+ }{
+ {
+ name: "successful request",
mockedClient: mock.NewMockedHTTPClient(
mock.WithRequestMatchHandler(
- mock.PostReposPullsCommentsByOwnerByRepoByPullNumber,
- http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
- w.WriteHeader(http.StatusUnprocessableEntity)
- w.Header().Set("Content-Type", "application/json")
- _, _ = w.Write([]byte(`{"message": "Validation Failed"}`))
- }),
+ mock.PostReposPullsRequestedReviewersByOwnerByRepoByPullNumber,
+ expect(t, expectations{
+ path: "/repos/owner/repo/pulls/1/requested_reviewers",
+ requestBody: map[string]any{
+ "reviewers": []any{"copilot-pull-request-reviewer[bot]"},
+ },
+ }).andThen(
+ mockResponse(t, http.StatusCreated, mockPR),
+ ),
),
),
- requestArgs: map[string]interface{}{
- "owner": "owner",
- "repo": "repo",
- "pull_number": float64(1),
- "body": "Great stuff!",
- "commit_id": "6dcb09b5b57875f334f61aebed695e2e4193db5e",
- "path": "file1.txt",
- "line": float64(2),
+ requestArgs: map[string]any{
+ "owner": "owner",
+ "repo": "repo",
+ "pullNumber": float64(1),
},
- expectError: true,
- expectedErrMsg: "failed to create pull request comment",
+ expectError: false,
},
{
- name: "reply creation fails",
+ name: "request fails",
mockedClient: mock.NewMockedHTTPClient(
mock.WithRequestMatchHandler(
- mock.PostReposPullsCommentsByOwnerByRepoByPullNumber,
+ mock.PostReposPullsRequestedReviewersByOwnerByRepoByPullNumber,
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNotFound)
- w.Header().Set("Content-Type", "application/json")
- _, _ = w.Write([]byte(`{"message": "Comment not found"}`))
+ _, _ = w.Write([]byte(`{"message": "Not Found"}`))
}),
),
),
- requestArgs: map[string]interface{}{
- "owner": "owner",
- "repo": "repo",
- "pull_number": float64(1),
- "body": "Good point, will fix!",
- "in_reply_to": float64(999),
+ requestArgs: map[string]any{
+ "owner": "owner",
+ "repo": "repo",
+ "pullNumber": float64(999),
},
expectError: true,
- expectedErrMsg: "failed to reply to pull request comment",
- },
- {
- name: "missing required parameters for comment",
- mockedClient: mock.NewMockedHTTPClient(),
- requestArgs: map[string]interface{}{
- "owner": "owner",
- "repo": "repo",
- "pull_number": float64(1),
- "body": "Great stuff!",
- // missing commit_id and path
- },
- expectError: false,
- expectedErrMsg: "missing required parameter: commit_id",
+ expectedErrMsg: "failed to request copilot review",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
- mockClient := github.NewClient(tc.mockedClient)
+ t.Parallel()
- _, handler := AddPullRequestReviewComment(stubGetClientFn(mockClient), translations.NullTranslationHelper)
+ client := github.NewClient(tc.mockedClient)
+ _, handler := RequestCopilotReview(stubGetClientFn(client), translations.NullTranslationHelper)
request := createMCPRequest(tc.requestArgs)
@@ -1888,31 +1647,647 @@ func Test_AddPullRequestReviewComment(t *testing.T) {
require.NoError(t, err)
assert.NotNil(t, result)
- require.Len(t, result.Content, 1)
+ assert.Len(t, result.Content, 1)
textContent := getTextResult(t, result)
- if tc.expectedErrMsg != "" {
- assert.Contains(t, textContent.Text, tc.expectedErrMsg)
+ require.Equal(t, "", textContent.Text)
+ })
+ }
+}
+
+func TestCreatePendingPullRequestReview(t *testing.T) {
+ t.Parallel()
+
+ // Verify tool definition once
+ mockClient := githubv4.NewClient(nil)
+ tool, _ := CreatePendingPullRequestReview(stubGetGQLClientFn(mockClient), translations.NullTranslationHelper)
+
+ assert.Equal(t, "create_pending_pull_request_review", 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, "pullNumber")
+ assert.Contains(t, tool.InputSchema.Properties, "commitID")
+ assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "pullNumber"})
+
+ tests := []struct {
+ name string
+ mockedClient *http.Client
+ requestArgs map[string]any
+ expectToolError bool
+ expectedToolErrMsg string
+ }{
+ {
+ name: "successful review creation",
+ mockedClient: githubv4mock.NewMockedHTTPClient(
+ githubv4mock.NewQueryMatcher(
+ struct {
+ Repository struct {
+ PullRequest struct {
+ ID githubv4.ID
+ } `graphql:"pullRequest(number: $prNum)"`
+ } `graphql:"repository(owner: $owner, name: $repo)"`
+ }{},
+ map[string]any{
+ "owner": githubv4.String("owner"),
+ "repo": githubv4.String("repo"),
+ "prNum": githubv4.Int(42),
+ },
+ githubv4mock.DataResponse(
+ map[string]any{
+ "repository": map[string]any{
+ "pullRequest": map[string]any{
+ "id": "PR_kwDODKw3uc6WYN1T",
+ },
+ },
+ },
+ ),
+ ),
+ githubv4mock.NewMutationMatcher(
+ struct {
+ AddPullRequestReview struct {
+ PullRequestReview struct {
+ ID githubv4.ID
+ }
+ } `graphql:"addPullRequestReview(input: $input)"`
+ }{},
+ githubv4.AddPullRequestReviewInput{
+ PullRequestID: githubv4.ID("PR_kwDODKw3uc6WYN1T"),
+ CommitOID: githubv4.NewGitObjectID("abcd1234"),
+ },
+ nil,
+ githubv4mock.DataResponse(map[string]any{}),
+ ),
+ ),
+ requestArgs: map[string]any{
+ "owner": "owner",
+ "repo": "repo",
+ "pullNumber": float64(42),
+ "commitID": "abcd1234",
+ },
+ expectToolError: false,
+ },
+ {
+ name: "failure to get pull request",
+ mockedClient: githubv4mock.NewMockedHTTPClient(
+ githubv4mock.NewQueryMatcher(
+ struct {
+ Repository struct {
+ PullRequest struct {
+ ID githubv4.ID
+ } `graphql:"pullRequest(number: $prNum)"`
+ } `graphql:"repository(owner: $owner, name: $repo)"`
+ }{},
+ map[string]any{
+ "owner": githubv4.String("owner"),
+ "repo": githubv4.String("repo"),
+ "prNum": githubv4.Int(42),
+ },
+ githubv4mock.ErrorResponse("expected test failure"),
+ ),
+ ),
+ requestArgs: map[string]any{
+ "owner": "owner",
+ "repo": "repo",
+ "pullNumber": float64(42),
+ "commitID": "abcd1234",
+ },
+ expectToolError: true,
+ expectedToolErrMsg: "expected test failure",
+ },
+ {
+ name: "failure to create pending review",
+ mockedClient: githubv4mock.NewMockedHTTPClient(
+ githubv4mock.NewQueryMatcher(
+ struct {
+ Repository struct {
+ PullRequest struct {
+ ID githubv4.ID
+ } `graphql:"pullRequest(number: $prNum)"`
+ } `graphql:"repository(owner: $owner, name: $repo)"`
+ }{},
+ map[string]any{
+ "owner": githubv4.String("owner"),
+ "repo": githubv4.String("repo"),
+ "prNum": githubv4.Int(42),
+ },
+ githubv4mock.DataResponse(
+ map[string]any{
+ "repository": map[string]any{
+ "pullRequest": map[string]any{
+ "id": "PR_kwDODKw3uc6WYN1T",
+ },
+ },
+ },
+ ),
+ ),
+ githubv4mock.NewMutationMatcher(
+ struct {
+ AddPullRequestReview struct {
+ PullRequestReview struct {
+ ID githubv4.ID
+ }
+ } `graphql:"addPullRequestReview(input: $input)"`
+ }{},
+ githubv4.AddPullRequestReviewInput{
+ PullRequestID: githubv4.ID("PR_kwDODKw3uc6WYN1T"),
+ CommitOID: githubv4.NewGitObjectID("abcd1234"),
+ },
+ nil,
+ githubv4mock.ErrorResponse("expected test failure"),
+ ),
+ ),
+ requestArgs: map[string]any{
+ "owner": "owner",
+ "repo": "repo",
+ "pullNumber": float64(42),
+ "commitID": "abcd1234",
+ },
+ expectToolError: true,
+ expectedToolErrMsg: "expected test failure",
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
+
+ // Setup client with mock
+ client := githubv4.NewClient(tc.mockedClient)
+ _, handler := CreatePendingPullRequestReview(stubGetGQLClientFn(client), translations.NullTranslationHelper)
+
+ // Create call request
+ request := createMCPRequest(tc.requestArgs)
+
+ // Call handler
+ result, err := handler(context.Background(), request)
+ require.NoError(t, err)
+
+ textContent := getTextResult(t, result)
+
+ if tc.expectToolError {
+ require.True(t, result.IsError)
+ assert.Contains(t, textContent.Text, tc.expectedToolErrMsg)
+ return
+ }
+
+ // Parse the result and get the text content if no error
+ require.Equal(t, textContent.Text, "pending pull request created")
+ })
+ }
+}
+
+func TestAddPullRequestReviewCommentToPendingReview(t *testing.T) {
+ t.Parallel()
+
+ // Verify tool definition once
+ mockClient := githubv4.NewClient(nil)
+ tool, _ := AddPullRequestReviewCommentToPendingReview(stubGetGQLClientFn(mockClient), translations.NullTranslationHelper)
+
+ assert.Equal(t, "add_pull_request_review_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")
+ assert.Contains(t, tool.InputSchema.Properties, "pullNumber")
+ assert.Contains(t, tool.InputSchema.Properties, "path")
+ assert.Contains(t, tool.InputSchema.Properties, "body")
+ assert.Contains(t, tool.InputSchema.Properties, "subjectType")
+ assert.Contains(t, tool.InputSchema.Properties, "line")
+ assert.Contains(t, tool.InputSchema.Properties, "side")
+ assert.Contains(t, tool.InputSchema.Properties, "startLine")
+ assert.Contains(t, tool.InputSchema.Properties, "startSide")
+ assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "pullNumber", "path", "body", "subjectType"})
+
+ tests := []struct {
+ name string
+ mockedClient *http.Client
+ requestArgs map[string]any
+ expectToolError bool
+ expectedToolErrMsg string
+ }{
+ {
+ name: "successful line comment addition",
+ requestArgs: map[string]any{
+ "owner": "owner",
+ "repo": "repo",
+ "pullNumber": float64(42),
+ "path": "file.go",
+ "body": "This is a test comment",
+ "subjectType": "LINE",
+ "line": float64(10),
+ "side": "RIGHT",
+ "startLine": float64(5),
+ "startSide": "RIGHT",
+ },
+ mockedClient: githubv4mock.NewMockedHTTPClient(
+ viewerQuery("williammartin"),
+ getLatestPendingReviewQuery(getLatestPendingReviewQueryParams{
+ author: "williammartin",
+ owner: "owner",
+ repo: "repo",
+ prNum: 42,
+
+ reviews: []getLatestPendingReviewQueryReview{
+ {
+ id: "PR_kwDODKw3uc6WYN1T",
+ state: "PENDING",
+ url: "https://github.com/owner/repo/pull/42",
+ },
+ },
+ }),
+ githubv4mock.NewMutationMatcher(
+ struct {
+ AddPullRequestReviewThread struct {
+ Thread struct {
+ ID githubv4.String // We don't need this, but a selector is required or GQL complains.
+ }
+ } `graphql:"addPullRequestReviewThread(input: $input)"`
+ }{},
+ githubv4.AddPullRequestReviewThreadInput{
+ Path: githubv4.String("file.go"),
+ Body: githubv4.String("This is a test comment"),
+ SubjectType: githubv4mock.Ptr(githubv4.PullRequestReviewThreadSubjectTypeLine),
+ Line: githubv4.NewInt(10),
+ Side: githubv4mock.Ptr(githubv4.DiffSideRight),
+ StartLine: githubv4.NewInt(5),
+ StartSide: githubv4mock.Ptr(githubv4.DiffSideRight),
+ PullRequestReviewID: githubv4.NewID("PR_kwDODKw3uc6WYN1T"),
+ },
+ nil,
+ githubv4mock.DataResponse(map[string]any{}),
+ ),
+ ),
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
+
+ // Setup client with mock
+ client := githubv4.NewClient(tc.mockedClient)
+ _, handler := AddPullRequestReviewCommentToPendingReview(stubGetGQLClientFn(client), translations.NullTranslationHelper)
+
+ // Create call request
+ request := createMCPRequest(tc.requestArgs)
+
+ // Call handler
+ result, err := handler(context.Background(), request)
+ require.NoError(t, err)
+
+ textContent := getTextResult(t, result)
+
+ if tc.expectToolError {
+ require.True(t, result.IsError)
+ assert.Contains(t, textContent.Text, tc.expectedToolErrMsg)
return
}
- var returnedComment github.PullRequestComment
- err = json.Unmarshal([]byte(getTextResult(t, result).Text), &returnedComment)
+ // Parse the result and get the text content if no error
+ require.Equal(t, textContent.Text, "pull request review comment successfully added to pending review")
+ })
+ }
+}
+
+func TestSubmitPendingPullRequestReview(t *testing.T) {
+ t.Parallel()
+
+ // Verify tool definition once
+ mockClient := githubv4.NewClient(nil)
+ tool, _ := SubmitPendingPullRequestReview(stubGetGQLClientFn(mockClient), translations.NullTranslationHelper)
+
+ assert.Equal(t, "submit_pending_pull_request_review", 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, "pullNumber")
+ assert.Contains(t, tool.InputSchema.Properties, "event")
+ assert.Contains(t, tool.InputSchema.Properties, "body")
+ assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "pullNumber", "event"})
+
+ tests := []struct {
+ name string
+ mockedClient *http.Client
+ requestArgs map[string]any
+ expectToolError bool
+ expectedToolErrMsg string
+ }{
+ {
+ name: "successful review submission",
+ requestArgs: map[string]any{
+ "owner": "owner",
+ "repo": "repo",
+ "pullNumber": float64(42),
+ "event": "COMMENT",
+ "body": "This is a test review",
+ },
+ mockedClient: githubv4mock.NewMockedHTTPClient(
+ viewerQuery("williammartin"),
+ getLatestPendingReviewQuery(getLatestPendingReviewQueryParams{
+ author: "williammartin",
+ owner: "owner",
+ repo: "repo",
+ prNum: 42,
+
+ reviews: []getLatestPendingReviewQueryReview{
+ {
+ id: "PR_kwDODKw3uc6WYN1T",
+ state: "PENDING",
+ url: "https://github.com/owner/repo/pull/42",
+ },
+ },
+ }),
+ githubv4mock.NewMutationMatcher(
+ struct {
+ SubmitPullRequestReview struct {
+ PullRequestReview struct {
+ ID githubv4.ID
+ }
+ } `graphql:"submitPullRequestReview(input: $input)"`
+ }{},
+ githubv4.SubmitPullRequestReviewInput{
+ PullRequestReviewID: githubv4.NewID("PR_kwDODKw3uc6WYN1T"),
+ Event: githubv4.PullRequestReviewEventComment,
+ Body: githubv4.NewString("This is a test review"),
+ },
+ nil,
+ githubv4mock.DataResponse(map[string]any{}),
+ ),
+ ),
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
+
+ // Setup client with mock
+ client := githubv4.NewClient(tc.mockedClient)
+ _, handler := SubmitPendingPullRequestReview(stubGetGQLClientFn(client), translations.NullTranslationHelper)
+
+ // Create call request
+ request := createMCPRequest(tc.requestArgs)
+
+ // Call handler
+ result, err := handler(context.Background(), request)
require.NoError(t, err)
- assert.Equal(t, *tc.expectedComment.ID, *returnedComment.ID)
- assert.Equal(t, *tc.expectedComment.Body, *returnedComment.Body)
+ textContent := getTextResult(t, result)
- // Only check Path, Line, and Side if they exist in the expected comment
- if tc.expectedComment.Path != nil {
- assert.Equal(t, *tc.expectedComment.Path, *returnedComment.Path)
+ if tc.expectToolError {
+ require.True(t, result.IsError)
+ assert.Contains(t, textContent.Text, tc.expectedToolErrMsg)
+ return
}
- if tc.expectedComment.Line != nil {
- assert.Equal(t, *tc.expectedComment.Line, *returnedComment.Line)
+
+ // Parse the result and get the text content if no error
+ require.Equal(t, "pending pull request review successfully submitted", textContent.Text)
+ })
+ }
+}
+
+func TestDeletePendingPullRequestReview(t *testing.T) {
+ t.Parallel()
+
+ // Verify tool definition once
+ mockClient := githubv4.NewClient(nil)
+ tool, _ := DeletePendingPullRequestReview(stubGetGQLClientFn(mockClient), translations.NullTranslationHelper)
+
+ assert.Equal(t, "delete_pending_pull_request_review", 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, "pullNumber")
+ assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "pullNumber"})
+
+ tests := []struct {
+ name string
+ requestArgs map[string]any
+ mockedClient *http.Client
+ expectToolError bool
+ expectedToolErrMsg string
+ }{
+ {
+ name: "successful review deletion",
+ requestArgs: map[string]any{
+ "owner": "owner",
+ "repo": "repo",
+ "pullNumber": float64(42),
+ },
+ mockedClient: githubv4mock.NewMockedHTTPClient(
+ viewerQuery("williammartin"),
+ getLatestPendingReviewQuery(getLatestPendingReviewQueryParams{
+ author: "williammartin",
+ owner: "owner",
+ repo: "repo",
+ prNum: 42,
+
+ reviews: []getLatestPendingReviewQueryReview{
+ {
+ id: "PR_kwDODKw3uc6WYN1T",
+ state: "PENDING",
+ url: "https://github.com/owner/repo/pull/42",
+ },
+ },
+ }),
+ githubv4mock.NewMutationMatcher(
+ struct {
+ DeletePullRequestReview struct {
+ PullRequestReview struct {
+ ID githubv4.ID
+ }
+ } `graphql:"deletePullRequestReview(input: $input)"`
+ }{},
+ githubv4.DeletePullRequestReviewInput{
+ PullRequestReviewID: githubv4.NewID("PR_kwDODKw3uc6WYN1T"),
+ },
+ nil,
+ githubv4mock.DataResponse(map[string]any{}),
+ ),
+ ),
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
+
+ // Setup client with mock
+ client := githubv4.NewClient(tc.mockedClient)
+ _, handler := DeletePendingPullRequestReview(stubGetGQLClientFn(client), translations.NullTranslationHelper)
+
+ // Create call request
+ request := createMCPRequest(tc.requestArgs)
+
+ // Call handler
+ result, err := handler(context.Background(), request)
+ require.NoError(t, err)
+
+ textContent := getTextResult(t, result)
+
+ if tc.expectToolError {
+ require.True(t, result.IsError)
+ assert.Contains(t, textContent.Text, tc.expectedToolErrMsg)
+ return
}
- if tc.expectedComment.Side != nil {
- assert.Equal(t, *tc.expectedComment.Side, *returnedComment.Side)
+
+ // Parse the result and get the text content if no error
+ require.Equal(t, "pending pull request review successfully deleted", textContent.Text)
+ })
+ }
+}
+
+func TestGetPullRequestDiff(t *testing.T) {
+ t.Parallel()
+
+ // Verify tool definition once
+ mockClient := github.NewClient(nil)
+ tool, _ := GetPullRequestDiff(stubGetClientFn(mockClient), translations.NullTranslationHelper)
+
+ assert.Equal(t, "get_pull_request_diff", 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, "pullNumber")
+ assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "pullNumber"})
+
+ stubbedDiff := `diff --git a/README.md b/README.md
+index 5d6e7b2..8a4f5c3 100644
+--- a/README.md
++++ b/README.md
+@@ -1,4 +1,6 @@
+ # Hello-World
+
+ Hello World project for GitHub
+
++## New Section
++
++This is a new section added in the pull request.`
+
+ tests := []struct {
+ name string
+ requestArgs map[string]any
+ mockedClient *http.Client
+ expectToolError bool
+ expectedToolErrMsg string
+ }{
+ {
+ name: "successful diff retrieval",
+ requestArgs: map[string]any{
+ "owner": "owner",
+ "repo": "repo",
+ "pullNumber": float64(42),
+ },
+ mockedClient: mock.NewMockedHTTPClient(
+ mock.WithRequestMatchHandler(
+ mock.GetReposPullsByOwnerByRepoByPullNumber,
+ // Should also expect Accept header to be application/vnd.github.v3.diff
+ expectPath(t, "/repos/owner/repo/pulls/42").andThen(
+ mockResponse(t, http.StatusOK, stubbedDiff),
+ ),
+ ),
+ ),
+ expectToolError: false,
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
+
+ // Setup client with mock
+ client := github.NewClient(tc.mockedClient)
+ _, handler := GetPullRequestDiff(stubGetClientFn(client), translations.NullTranslationHelper)
+
+ // Create call request
+ request := createMCPRequest(tc.requestArgs)
+
+ // Call handler
+ result, err := handler(context.Background(), request)
+ require.NoError(t, err)
+
+ textContent := getTextResult(t, result)
+
+ if tc.expectToolError {
+ require.True(t, result.IsError)
+ assert.Contains(t, textContent.Text, tc.expectedToolErrMsg)
+ return
}
+
+ // Parse the result and get the text content if no error
+ require.Equal(t, stubbedDiff, textContent.Text)
})
}
}
+
+func viewerQuery(login string) githubv4mock.Matcher {
+ return githubv4mock.NewQueryMatcher(
+ struct {
+ Viewer struct {
+ Login githubv4.String
+ } `graphql:"viewer"`
+ }{},
+ map[string]any{},
+ githubv4mock.DataResponse(map[string]any{
+ "viewer": map[string]any{
+ "login": login,
+ },
+ }),
+ )
+}
+
+type getLatestPendingReviewQueryReview struct {
+ id string
+ state string
+ url string
+}
+
+type getLatestPendingReviewQueryParams struct {
+ author string
+ owner string
+ repo string
+ prNum int32
+
+ reviews []getLatestPendingReviewQueryReview
+}
+
+func getLatestPendingReviewQuery(p getLatestPendingReviewQueryParams) githubv4mock.Matcher {
+ return githubv4mock.NewQueryMatcher(
+ struct {
+ Repository struct {
+ PullRequest struct {
+ Reviews struct {
+ Nodes []struct {
+ ID githubv4.ID
+ State githubv4.PullRequestReviewState
+ URL githubv4.URI
+ }
+ } `graphql:"reviews(first: 1, author: $author)"`
+ } `graphql:"pullRequest(number: $prNum)"`
+ } `graphql:"repository(owner: $owner, name: $name)"`
+ }{},
+ map[string]any{
+ "author": githubv4.String(p.author),
+ "owner": githubv4.String(p.owner),
+ "name": githubv4.String(p.repo),
+ "prNum": githubv4.Int(p.prNum),
+ },
+ githubv4mock.DataResponse(
+ map[string]any{
+ "repository": map[string]any{
+ "pullRequest": map[string]any{
+ "reviews": map[string]any{
+ "nodes": []any{
+ map[string]any{
+ "id": p.reviews[0].id,
+ "state": p.reviews[0].state,
+ "url": p.reviews[0].url,
+ },
+ },
+ },
+ },
+ },
+ },
+ ),
+ )
+}
diff --git a/pkg/github/repositories.go b/pkg/github/repositories.go
index 519487300..3475167b1 100644
--- a/pkg/github/repositories.go
+++ b/pkg/github/repositories.go
@@ -2,13 +2,17 @@ package github
import (
"context"
+ "encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
+ "net/url"
+ "strings"
+ "github.com/github/github-mcp-server/pkg/raw"
"github.com/github/github-mcp-server/pkg/translations"
- "github.com/google/go-github/v69/github"
+ "github.com/google/go-github/v72/github"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
)
@@ -16,6 +20,10 @@ import (
func GetCommit(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
return mcp.NewTool("get_commit",
mcp.WithDescription(t("TOOL_GET_COMMITS_DESCRIPTION", "Get details for a commit from a GitHub repository")),
+ mcp.WithToolAnnotation(mcp.ToolAnnotation{
+ Title: t("TOOL_GET_COMMITS_USER_TITLE", "Get commit details"),
+ ReadOnlyHint: ToBoolPtr(true),
+ }),
mcp.WithString("owner",
mcp.Required(),
mcp.Description("Repository owner"),
@@ -31,15 +39,15 @@ func GetCommit(getClient GetClientFn, t translations.TranslationHelperFunc) (too
WithPagination(),
),
func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
- owner, err := requiredParam[string](request, "owner")
+ owner, err := RequiredParam[string](request, "owner")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
- repo, err := requiredParam[string](request, "repo")
+ repo, err := RequiredParam[string](request, "repo")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
- sha, err := requiredParam[string](request, "sha")
+ sha, err := RequiredParam[string](request, "sha")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
@@ -84,6 +92,10 @@ func GetCommit(getClient GetClientFn, t translations.TranslationHelperFunc) (too
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.WithToolAnnotation(mcp.ToolAnnotation{
+ Title: t("TOOL_LIST_COMMITS_USER_TITLE", "List commits"),
+ ReadOnlyHint: ToBoolPtr(true),
+ }),
mcp.WithString("owner",
mcp.Required(),
mcp.Description("Repository owner"),
@@ -98,11 +110,11 @@ func ListCommits(getClient GetClientFn, t translations.TranslationHelperFunc) (t
WithPagination(),
),
func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
- owner, err := requiredParam[string](request, "owner")
+ owner, err := RequiredParam[string](request, "owner")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
- repo, err := requiredParam[string](request, "repo")
+ repo, err := RequiredParam[string](request, "repo")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
@@ -154,6 +166,10 @@ func ListCommits(getClient GetClientFn, t translations.TranslationHelperFunc) (t
func ListBranches(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
return mcp.NewTool("list_branches",
mcp.WithDescription(t("TOOL_LIST_BRANCHES_DESCRIPTION", "List branches in a GitHub repository")),
+ mcp.WithToolAnnotation(mcp.ToolAnnotation{
+ Title: t("TOOL_LIST_BRANCHES_USER_TITLE", "List branches"),
+ ReadOnlyHint: ToBoolPtr(true),
+ }),
mcp.WithString("owner",
mcp.Required(),
mcp.Description("Repository owner"),
@@ -165,11 +181,11 @@ func ListBranches(getClient GetClientFn, t translations.TranslationHelperFunc) (
WithPagination(),
),
func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
- owner, err := requiredParam[string](request, "owner")
+ owner, err := RequiredParam[string](request, "owner")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
- repo, err := requiredParam[string](request, "repo")
+ repo, err := RequiredParam[string](request, "repo")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
@@ -216,7 +232,11 @@ func ListBranches(getClient GetClientFn, t translations.TranslationHelperFunc) (
// CreateOrUpdateFile creates a tool to create or update a file in a GitHub repository.
func CreateOrUpdateFile(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
return mcp.NewTool("create_or_update_file",
- mcp.WithDescription(t("TOOL_CREATE_OR_UPDATE_FILE_DESCRIPTION", "Create or update a single file in a GitHub repository")),
+ mcp.WithDescription(t("TOOL_CREATE_OR_UPDATE_FILE_DESCRIPTION", "Create or update a single file in a GitHub repository. If updating, you must provide the SHA of the file you want to update.")),
+ mcp.WithToolAnnotation(mcp.ToolAnnotation{
+ Title: t("TOOL_CREATE_OR_UPDATE_FILE_USER_TITLE", "Create or update file"),
+ ReadOnlyHint: ToBoolPtr(false),
+ }),
mcp.WithString("owner",
mcp.Required(),
mcp.Description("Repository owner (username or organization)"),
@@ -246,32 +266,32 @@ func CreateOrUpdateFile(getClient GetClientFn, t translations.TranslationHelperF
),
),
func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
- owner, err := requiredParam[string](request, "owner")
+ owner, err := RequiredParam[string](request, "owner")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
- repo, err := requiredParam[string](request, "repo")
+ repo, err := RequiredParam[string](request, "repo")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
- path, err := requiredParam[string](request, "path")
+ path, err := RequiredParam[string](request, "path")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
- content, err := requiredParam[string](request, "content")
+ content, err := RequiredParam[string](request, "content")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
- message, err := requiredParam[string](request, "message")
+ message, err := RequiredParam[string](request, "message")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
- branch, err := requiredParam[string](request, "branch")
+ branch, err := RequiredParam[string](request, "branch")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
- // Convert content to base64
+ // json.Marshal encodes byte arrays with base64, which is required for the API.
contentBytes := []byte(content)
// Create the file options
@@ -322,6 +342,10 @@ func CreateOrUpdateFile(getClient GetClientFn, t translations.TranslationHelperF
func CreateRepository(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
return mcp.NewTool("create_repository",
mcp.WithDescription(t("TOOL_CREATE_REPOSITORY_DESCRIPTION", "Create a new GitHub repository in your account")),
+ mcp.WithToolAnnotation(mcp.ToolAnnotation{
+ Title: t("TOOL_CREATE_REPOSITORY_USER_TITLE", "Create repository"),
+ ReadOnlyHint: ToBoolPtr(false),
+ }),
mcp.WithString("name",
mcp.Required(),
mcp.Description("Repository name"),
@@ -337,7 +361,7 @@ func CreateRepository(getClient GetClientFn, t translations.TranslationHelperFun
),
),
func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
- name, err := requiredParam[string](request, "name")
+ name, err := RequiredParam[string](request, "name")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
@@ -389,9 +413,13 @@ func CreateRepository(getClient GetClientFn, t translations.TranslationHelperFun
}
// GetFileContents creates a tool to get the contents of a file or directory from a GitHub repository.
-func GetFileContents(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
+func GetFileContents(getClient GetClientFn, getRawClient raw.GetRawClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
return mcp.NewTool("get_file_contents",
mcp.WithDescription(t("TOOL_GET_FILE_CONTENTS_DESCRIPTION", "Get the contents of a file or directory from a GitHub repository")),
+ mcp.WithToolAnnotation(mcp.ToolAnnotation{
+ Title: t("TOOL_GET_FILE_CONTENTS_USER_TITLE", "Get file or directory contents"),
+ ReadOnlyHint: ToBoolPtr(true),
+ }),
mcp.WithString("owner",
mcp.Required(),
mcp.Description("Repository owner (username or organization)"),
@@ -402,22 +430,22 @@ func GetFileContents(getClient GetClientFn, t translations.TranslationHelperFunc
),
mcp.WithString("path",
mcp.Required(),
- mcp.Description("Path to file/directory"),
+ mcp.Description("Path to file/directory (directories must end with a slash '/')"),
),
mcp.WithString("branch",
mcp.Description("Branch to get contents from"),
),
),
func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
- owner, err := requiredParam[string](request, "owner")
+ owner, err := RequiredParam[string](request, "owner")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
- repo, err := requiredParam[string](request, "repo")
+ repo, err := RequiredParam[string](request, "repo")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
- path, err := requiredParam[string](request, "path")
+ path, err := RequiredParam[string](request, "path")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
@@ -426,38 +454,92 @@ func GetFileContents(getClient GetClientFn, t translations.TranslationHelperFunc
return mcp.NewToolResultError(err.Error()), nil
}
- client, err := getClient(ctx)
- if err != nil {
- return nil, fmt.Errorf("failed to get GitHub client: %w", err)
+ // 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, "/") {
+ rawOpts := &raw.RawContentOpts{}
+ if branch != "" {
+ rawOpts.Ref = "refs/heads/" + branch
+ }
+ rawClient, err := getRawClient(ctx)
+ if err != nil {
+ return mcp.NewToolResultError("failed to get GitHub raw content client"), nil
+ }
+ resp, err := rawClient.GetRawContent(ctx, owner, repo, path, rawOpts)
+ if err != nil {
+ return mcp.NewToolResultError("failed to get raw repository content"), nil
+ }
+ defer func() {
+ _ = 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 the raw content is found, return it directly
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return mcp.NewToolResultError("failed to read response body"), nil
+ }
+ contentType := resp.Header.Get("Content-Type")
+
+ var resourceURI string
+ if branch == "" {
+ // do a safe url join
+ resourceURI, err = url.JoinPath("repo://", owner, repo, "contents", path)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create resource URI: %w", err)
+ }
+ } else {
+ resourceURI, err = url.JoinPath("repo://", owner, repo, "refs", "heads", branch, "contents", path)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create resource URI: %w", err)
+ }
+ }
+ if strings.HasPrefix(contentType, "application") || strings.HasPrefix(contentType, "text") {
+ return mcp.NewToolResultResource("successfully downloaded text file", mcp.TextResourceContents{
+ URI: resourceURI,
+ Text: string(body),
+ MIMEType: contentType,
+ }), nil
+ }
+
+ return mcp.NewToolResultResource("successfully downloaded binary file", mcp.BlobResourceContents{
+ URI: resourceURI,
+ Blob: base64.StdEncoding.EncodeToString(body),
+ MIMEType: contentType,
+ }), nil
+
+ }
}
- opts := &github.RepositoryContentGetOptions{Ref: branch}
- fileContent, dirContent, resp, err := client.Repositories.GetContents(ctx, owner, repo, path, opts)
+
+ client, err := getClient(ctx)
if err != nil {
- return nil, fmt.Errorf("failed to get file contents: %w", err)
+ return mcp.NewToolResultError("failed to get GitHub client"), nil
}
- defer func() { _ = resp.Body.Close() }()
- if resp.StatusCode != 200 {
- body, err := io.ReadAll(resp.Body)
+ if strings.HasSuffix(path, "/") {
+ opts := &github.RepositoryContentGetOptions{Ref: branch}
+ _, dirContent, resp, err := client.Repositories.GetContents(ctx, owner, repo, path, opts)
if err != nil {
- return nil, fmt.Errorf("failed to read response body: %w", err)
+ return mcp.NewToolResultError("failed to get file contents"), nil
}
- return mcp.NewToolResultError(fmt.Sprintf("failed to get file contents: %s", string(body))), nil
- }
+ defer func() { _ = resp.Body.Close() }()
- var result interface{}
- if fileContent != nil {
- result = fileContent
- } else {
- result = dirContent
- }
+ if resp.StatusCode != 200 {
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return mcp.NewToolResultError("failed to read response body"), nil
+ }
+ return mcp.NewToolResultError(fmt.Sprintf("failed to get file contents: %s", string(body))), nil
+ }
- r, err := json.Marshal(result)
- if err != nil {
- return nil, fmt.Errorf("failed to marshal response: %w", err)
+ r, err := json.Marshal(dirContent)
+ if err != nil {
+ return mcp.NewToolResultError("failed to marshal response"), nil
+ }
+ return mcp.NewToolResultText(string(r)), nil
}
-
- return mcp.NewToolResultText(string(r)), 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
}
}
@@ -465,6 +547,10 @@ func GetFileContents(getClient GetClientFn, t translations.TranslationHelperFunc
func ForkRepository(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
return mcp.NewTool("fork_repository",
mcp.WithDescription(t("TOOL_FORK_REPOSITORY_DESCRIPTION", "Fork a GitHub repository to your account or specified organization")),
+ mcp.WithToolAnnotation(mcp.ToolAnnotation{
+ Title: t("TOOL_FORK_REPOSITORY_USER_TITLE", "Fork repository"),
+ ReadOnlyHint: ToBoolPtr(false),
+ }),
mcp.WithString("owner",
mcp.Required(),
mcp.Description("Repository owner"),
@@ -478,11 +564,11 @@ func ForkRepository(getClient GetClientFn, t translations.TranslationHelperFunc)
),
),
func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
- owner, err := requiredParam[string](request, "owner")
+ owner, err := RequiredParam[string](request, "owner")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
- repo, err := requiredParam[string](request, "repo")
+ repo, err := RequiredParam[string](request, "repo")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
@@ -528,10 +614,174 @@ func ForkRepository(getClient GetClientFn, t translations.TranslationHelperFunc)
}
}
+// DeleteFile creates a tool to delete a file in a GitHub repository.
+// This tool uses a more roundabout way of deleting a file than just using the client.Repositories.DeleteFile.
+// This is because REST file deletion endpoint (and client.Repositories.DeleteFile) don't add commit signing to the deletion commit,
+// unlike how the endpoint backing the create_or_update_files tool does. This appears to be a quirk of the API.
+// The approach implemented here gets automatic commit signing when used with either the github-actions user or as an app,
+// both of which suit an LLM well.
+func DeleteFile(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
+ return mcp.NewTool("delete_file",
+ mcp.WithDescription(t("TOOL_DELETE_FILE_DESCRIPTION", "Delete a file from a GitHub repository")),
+ mcp.WithToolAnnotation(mcp.ToolAnnotation{
+ Title: t("TOOL_DELETE_FILE_USER_TITLE", "Delete file"),
+ ReadOnlyHint: ToBoolPtr(false),
+ DestructiveHint: ToBoolPtr(true),
+ }),
+ mcp.WithString("owner",
+ mcp.Required(),
+ mcp.Description("Repository owner (username or organization)"),
+ ),
+ mcp.WithString("repo",
+ mcp.Required(),
+ mcp.Description("Repository name"),
+ ),
+ mcp.WithString("path",
+ mcp.Required(),
+ mcp.Description("Path to the file to delete"),
+ ),
+ mcp.WithString("message",
+ mcp.Required(),
+ mcp.Description("Commit message"),
+ ),
+ mcp.WithString("branch",
+ mcp.Required(),
+ mcp.Description("Branch to delete the file from"),
+ ),
+ ),
+ 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
+ }
+ path, err := RequiredParam[string](request, "path")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+ message, err := RequiredParam[string](request, "message")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+ branch, err := RequiredParam[string](request, "branch")
+ 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)
+ }
+
+ // Get the reference for the branch
+ ref, resp, err := client.Git.GetRef(ctx, owner, repo, "refs/heads/"+branch)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get branch reference: %w", err)
+ }
+ defer func() { _ = resp.Body.Close() }()
+
+ // Get the commit object that the branch points to
+ baseCommit, resp, err := client.Git.GetCommit(ctx, owner, repo, *ref.Object.SHA)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get base commit: %w", err)
+ }
+ defer func() { _ = resp.Body.Close() }()
+
+ if resp.StatusCode != http.StatusOK {
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, fmt.Errorf("failed to read response body: %w", err)
+ }
+ return mcp.NewToolResultError(fmt.Sprintf("failed to get commit: %s", string(body))), nil
+ }
+
+ // Create a tree entry for the file deletion by setting SHA to nil
+ treeEntries := []*github.TreeEntry{
+ {
+ Path: github.Ptr(path),
+ Mode: github.Ptr("100644"), // Regular file mode
+ Type: github.Ptr("blob"),
+ SHA: nil, // Setting SHA to nil deletes the file
+ },
+ }
+
+ // Create a new tree with the deletion
+ newTree, resp, err := client.Git.CreateTree(ctx, owner, repo, *baseCommit.Tree.SHA, treeEntries)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create tree: %w", err)
+ }
+ defer func() { _ = resp.Body.Close() }()
+
+ if resp.StatusCode != http.StatusCreated {
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, fmt.Errorf("failed to read response body: %w", err)
+ }
+ return mcp.NewToolResultError(fmt.Sprintf("failed to create tree: %s", string(body))), nil
+ }
+
+ // Create a new commit with the new tree
+ commit := &github.Commit{
+ Message: github.Ptr(message),
+ Tree: newTree,
+ Parents: []*github.Commit{{SHA: baseCommit.SHA}},
+ }
+ newCommit, resp, err := client.Git.CreateCommit(ctx, owner, repo, commit, nil)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create commit: %w", err)
+ }
+ defer func() { _ = resp.Body.Close() }()
+
+ if resp.StatusCode != http.StatusCreated {
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, fmt.Errorf("failed to read response body: %w", err)
+ }
+ return mcp.NewToolResultError(fmt.Sprintf("failed to create commit: %s", string(body))), nil
+ }
+
+ // Update the branch reference to point to the new commit
+ ref.Object.SHA = newCommit.SHA
+ _, resp, err = client.Git.UpdateRef(ctx, owner, repo, ref, false)
+ if err != nil {
+ return nil, fmt.Errorf("failed to update reference: %w", err)
+ }
+ defer func() { _ = resp.Body.Close() }()
+
+ if resp.StatusCode != http.StatusOK {
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, fmt.Errorf("failed to read response body: %w", err)
+ }
+ return mcp.NewToolResultError(fmt.Sprintf("failed to update reference: %s", string(body))), nil
+ }
+
+ // Create a response similar to what the DeleteFile API would return
+ response := map[string]interface{}{
+ "commit": newCommit,
+ "content": nil,
+ }
+
+ r, err := json.Marshal(response)
+ if err != nil {
+ return nil, fmt.Errorf("failed to marshal response: %w", err)
+ }
+
+ return mcp.NewToolResultText(string(r)), nil
+ }
+}
+
// CreateBranch creates a tool to create a new branch.
func CreateBranch(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
return mcp.NewTool("create_branch",
mcp.WithDescription(t("TOOL_CREATE_BRANCH_DESCRIPTION", "Create a new branch in a GitHub repository")),
+ mcp.WithToolAnnotation(mcp.ToolAnnotation{
+ Title: t("TOOL_CREATE_BRANCH_USER_TITLE", "Create branch"),
+ ReadOnlyHint: ToBoolPtr(false),
+ }),
mcp.WithString("owner",
mcp.Required(),
mcp.Description("Repository owner"),
@@ -549,15 +799,15 @@ func CreateBranch(getClient GetClientFn, t translations.TranslationHelperFunc) (
),
),
func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
- owner, err := requiredParam[string](request, "owner")
+ owner, err := RequiredParam[string](request, "owner")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
- repo, err := requiredParam[string](request, "repo")
+ repo, err := RequiredParam[string](request, "repo")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
- branch, err := requiredParam[string](request, "branch")
+ branch, err := RequiredParam[string](request, "branch")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
@@ -617,6 +867,10 @@ func CreateBranch(getClient GetClientFn, t translations.TranslationHelperFunc) (
func PushFiles(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
return mcp.NewTool("push_files",
mcp.WithDescription(t("TOOL_PUSH_FILES_DESCRIPTION", "Push multiple files to a GitHub repository in a single commit")),
+ mcp.WithToolAnnotation(mcp.ToolAnnotation{
+ Title: t("TOOL_PUSH_FILES_USER_TITLE", "Push files to repository"),
+ ReadOnlyHint: ToBoolPtr(false),
+ }),
mcp.WithString("owner",
mcp.Required(),
mcp.Description("Repository owner"),
@@ -655,25 +909,25 @@ func PushFiles(getClient GetClientFn, t translations.TranslationHelperFunc) (too
),
),
func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
- owner, err := requiredParam[string](request, "owner")
+ owner, err := RequiredParam[string](request, "owner")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
- repo, err := requiredParam[string](request, "repo")
+ repo, err := RequiredParam[string](request, "repo")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
- branch, err := requiredParam[string](request, "branch")
+ branch, err := RequiredParam[string](request, "branch")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
- message, err := requiredParam[string](request, "message")
+ message, err := RequiredParam[string](request, "message")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
// Parse files parameter - this should be an array of objects with path and content
- filesObj, ok := request.Params.Arguments["files"].([]interface{})
+ filesObj, ok := request.GetArguments()["files"].([]interface{})
if !ok {
return mcp.NewToolResultError("files parameter must be an array of objects with path and content"), nil
}
@@ -760,3 +1014,147 @@ func PushFiles(getClient GetClientFn, t translations.TranslationHelperFunc) (too
return mcp.NewToolResultText(string(r)), nil
}
}
+
+// ListTags creates a tool to list tags in a GitHub repository.
+func ListTags(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
+ return mcp.NewTool("list_tags",
+ mcp.WithDescription(t("TOOL_LIST_TAGS_DESCRIPTION", "List git tags in a GitHub repository")),
+ mcp.WithToolAnnotation(mcp.ToolAnnotation{
+ Title: t("TOOL_LIST_TAGS_USER_TITLE", "List tags"),
+ ReadOnlyHint: ToBoolPtr(true),
+ }),
+ mcp.WithString("owner",
+ mcp.Required(),
+ mcp.Description("Repository owner"),
+ ),
+ mcp.WithString("repo",
+ mcp.Required(),
+ mcp.Description("Repository name"),
+ ),
+ WithPagination(),
+ ),
+ func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
+ owner, err := RequiredParam[string](request, "owner")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+ repo, err := RequiredParam[string](request, "repo")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+ pagination, err := OptionalPaginationParams(request)
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+
+ opts := &github.ListOptions{
+ Page: pagination.page,
+ PerPage: pagination.perPage,
+ }
+
+ client, err := getClient(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get GitHub client: %w", err)
+ }
+
+ tags, resp, err := client.Repositories.ListTags(ctx, owner, repo, opts)
+ if err != nil {
+ return nil, fmt.Errorf("failed to list tags: %w", err)
+ }
+ defer func() { _ = resp.Body.Close() }()
+
+ if resp.StatusCode != http.StatusOK {
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, fmt.Errorf("failed to read response body: %w", err)
+ }
+ return mcp.NewToolResultError(fmt.Sprintf("failed to list tags: %s", string(body))), nil
+ }
+
+ r, err := json.Marshal(tags)
+ if err != nil {
+ return nil, fmt.Errorf("failed to marshal response: %w", err)
+ }
+
+ return mcp.NewToolResultText(string(r)), nil
+ }
+}
+
+// GetTag creates a tool to get details about a specific tag in a GitHub repository.
+func GetTag(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
+ return mcp.NewTool("get_tag",
+ mcp.WithDescription(t("TOOL_GET_TAG_DESCRIPTION", "Get details about a specific git tag in a GitHub repository")),
+ mcp.WithToolAnnotation(mcp.ToolAnnotation{
+ Title: t("TOOL_GET_TAG_USER_TITLE", "Get tag details"),
+ ReadOnlyHint: ToBoolPtr(true),
+ }),
+ mcp.WithString("owner",
+ mcp.Required(),
+ mcp.Description("Repository owner"),
+ ),
+ mcp.WithString("repo",
+ mcp.Required(),
+ mcp.Description("Repository name"),
+ ),
+ mcp.WithString("tag",
+ mcp.Required(),
+ mcp.Description("Tag name"),
+ ),
+ ),
+ func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
+ owner, err := RequiredParam[string](request, "owner")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+ repo, err := RequiredParam[string](request, "repo")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+ tag, err := RequiredParam[string](request, "tag")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+
+ client, err := getClient(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get GitHub client: %w", err)
+ }
+
+ // First get the tag reference
+ ref, resp, err := client.Git.GetRef(ctx, owner, repo, "refs/tags/"+tag)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get tag reference: %w", err)
+ }
+ defer func() { _ = resp.Body.Close() }()
+
+ if resp.StatusCode != http.StatusOK {
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, fmt.Errorf("failed to read response body: %w", err)
+ }
+ return mcp.NewToolResultError(fmt.Sprintf("failed to get tag reference: %s", string(body))), nil
+ }
+
+ // Then get the tag object
+ tagObj, resp, err := client.Git.GetTag(ctx, owner, repo, *ref.Object.SHA)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get tag object: %w", err)
+ }
+ defer func() { _ = resp.Body.Close() }()
+
+ if resp.StatusCode != http.StatusOK {
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, fmt.Errorf("failed to read response body: %w", err)
+ }
+ return mcp.NewToolResultError(fmt.Sprintf("failed to get tag object: %s", string(body))), nil
+ }
+
+ r, err := json.Marshal(tagObj)
+ if err != nil {
+ return nil, fmt.Errorf("failed to marshal response: %w", err)
+ }
+
+ return mcp.NewToolResultText(string(r)), nil
+ }
+}
diff --git a/pkg/github/repositories_test.go b/pkg/github/repositories_test.go
index 5b8129fe7..c2585341e 100644
--- a/pkg/github/repositories_test.go
+++ b/pkg/github/repositories_test.go
@@ -2,13 +2,16 @@ package github
import (
"context"
+ "encoding/base64"
"encoding/json"
"net/http"
+ "net/url"
"testing"
"time"
+ "github.com/github/github-mcp-server/pkg/raw"
"github.com/github/github-mcp-server/pkg/translations"
- "github.com/google/go-github/v69/github"
+ "github.com/google/go-github/v72/github"
"github.com/mark3labs/mcp-go/mcp"
"github.com/migueleliasweb/go-github-mock/src/mock"
"github.com/stretchr/testify/assert"
@@ -18,7 +21,8 @@ import (
func Test_GetFileContents(t *testing.T) {
// Verify tool definition once
mockClient := github.NewClient(nil)
- tool, _ := GetFileContents(stubGetClientFn(mockClient), translations.NullTranslationHelper)
+ mockRawClient := raw.NewClient(mockClient, &url.URL{Scheme: "https", Host: "raw.githubusercontent.com", Path: "/"})
+ tool, _ := GetFileContents(stubGetClientFn(mockClient), stubGetRawClientFn(mockRawClient), translations.NullTranslationHelper)
assert.Equal(t, "get_file_contents", tool.Name)
assert.NotEmpty(t, tool.Description)
@@ -28,17 +32,8 @@ func Test_GetFileContents(t *testing.T) {
assert.Contains(t, tool.InputSchema.Properties, "branch")
assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "path"})
- // Setup mock file content for success case
- mockFileContent := &github.RepositoryContent{
- Type: github.Ptr("file"),
- Name: github.Ptr("README.md"),
- Path: github.Ptr("README.md"),
- Content: github.Ptr("IyBUZXN0IFJlcG9zaXRvcnkKClRoaXMgaXMgYSB0ZXN0IHJlcG9zaXRvcnku"), // Base64 encoded "# Test Repository\n\nThis is a test repository."
- SHA: github.Ptr("abc123"),
- Size: github.Ptr(42),
- HTMLURL: github.Ptr("https://github.com/owner/repo/blob/main/README.md"),
- DownloadURL: github.Ptr("https://raw.githubusercontent.com/owner/repo/main/README.md"),
- }
+ // Mock response for raw content
+ mockRawContent := []byte("# Test Repository\n\nThis is a test repository.")
// Setup mock directory content for success case
mockDirContent := []*github.RepositoryContent{
@@ -66,17 +61,17 @@ func Test_GetFileContents(t *testing.T) {
expectError bool
expectedResult interface{}
expectedErrMsg string
+ expectStatus int
}{
{
- name: "successful file content fetch",
+ name: "successful text content fetch",
mockedClient: mock.NewMockedHTTPClient(
mock.WithRequestMatchHandler(
- mock.GetReposContentsByOwnerByRepoByPath,
- expectQueryParams(t, map[string]string{
- "ref": "main",
- }).andThen(
- mockResponse(t, http.StatusOK, mockFileContent),
- ),
+ raw.GetRawReposContentsByOwnerByRepoByBranchByPath,
+ http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.Header().Set("Content-Type", "text/markdown")
+ _, _ = w.Write(mockRawContent)
+ }),
),
),
requestArgs: map[string]interface{}{
@@ -85,8 +80,36 @@ func Test_GetFileContents(t *testing.T) {
"path": "README.md",
"branch": "main",
},
- expectError: false,
- expectedResult: mockFileContent,
+ expectError: false,
+ expectedResult: mcp.TextResourceContents{
+ URI: "repo://owner/repo/refs/heads/main/contents/README.md",
+ Text: "# Test Repository\n\nThis is a test repository.",
+ MIMEType: "text/markdown",
+ },
+ },
+ {
+ name: "successful file blob content fetch",
+ mockedClient: mock.NewMockedHTTPClient(
+ mock.WithRequestMatchHandler(
+ raw.GetRawReposContentsByOwnerByRepoByBranchByPath,
+ http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.Header().Set("Content-Type", "image/png")
+ _, _ = w.Write(mockRawContent)
+ }),
+ ),
+ ),
+ requestArgs: map[string]interface{}{
+ "owner": "owner",
+ "repo": "repo",
+ "path": "test.png",
+ "branch": "main",
+ },
+ expectError: false,
+ expectedResult: mcp.BlobResourceContents{
+ URI: "repo://owner/repo/refs/heads/main/contents/test.png",
+ Blob: base64.StdEncoding.EncodeToString(mockRawContent),
+ MIMEType: "image/png",
+ },
},
{
name: "successful directory content fetch",
@@ -97,11 +120,19 @@ func Test_GetFileContents(t *testing.T) {
mockResponse(t, http.StatusOK, mockDirContent),
),
),
+ mock.WithRequestMatchHandler(
+ raw.GetRawReposContentsByOwnerByRepoByPath,
+ expectQueryParams(t, map[string]string{
+ "branch": "main",
+ }).andThen(
+ mockResponse(t, http.StatusNotFound, nil),
+ ),
+ ),
),
requestArgs: map[string]interface{}{
"owner": "owner",
"repo": "repo",
- "path": "src",
+ "path": "src/",
},
expectError: false,
expectedResult: mockDirContent,
@@ -116,6 +147,13 @@ func Test_GetFileContents(t *testing.T) {
_, _ = w.Write([]byte(`{"message": "Not Found"}`))
}),
),
+ mock.WithRequestMatchHandler(
+ raw.GetRawReposContentsByOwnerByRepoByPath,
+ http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(http.StatusNotFound)
+ _, _ = w.Write([]byte(`{"message": "Not Found"}`))
+ }),
+ ),
),
requestArgs: map[string]interface{}{
"owner": "owner",
@@ -123,8 +161,8 @@ func Test_GetFileContents(t *testing.T) {
"path": "nonexistent.md",
"branch": "main",
},
- expectError: true,
- expectedErrMsg: "failed to get file contents",
+ expectError: false,
+ expectedResult: 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."),
},
}
@@ -132,20 +170,11 @@ func Test_GetFileContents(t *testing.T) {
t.Run(tc.name, func(t *testing.T) {
// Setup client with mock
client := github.NewClient(tc.mockedClient)
- _, handler := GetFileContents(stubGetClientFn(client), translations.NullTranslationHelper)
+ mockRawClient := raw.NewClient(client, &url.URL{Scheme: "https", Host: "raw.example.com", Path: "/"})
+ _, handler := GetFileContents(stubGetClientFn(client), stubGetRawClientFn(mockRawClient), translations.NullTranslationHelper)
// Create call request
- request := mcp.CallToolRequest{
- Params: struct {
- Name string `json:"name"`
- Arguments map[string]interface{} `json:"arguments,omitempty"`
- Meta *struct {
- ProgressToken mcp.ProgressToken `json:"progressToken,omitempty"`
- } `json:"_meta,omitempty"`
- }{
- Arguments: tc.requestArgs,
- },
- }
+ request := createMCPRequest(tc.requestArgs)
// Call handler
result, err := handler(context.Background(), request)
@@ -158,20 +187,17 @@ func Test_GetFileContents(t *testing.T) {
}
require.NoError(t, err)
-
- // Parse the result and get the text content if no error
- textContent := getTextResult(t, result)
-
- // Verify based on expected type
+ // Use the correct result helper based on the expected type
switch expected := tc.expectedResult.(type) {
- case *github.RepositoryContent:
- var returnedContent github.RepositoryContent
- err = json.Unmarshal([]byte(textContent.Text), &returnedContent)
- require.NoError(t, err)
- assert.Equal(t, *expected.Name, *returnedContent.Name)
- assert.Equal(t, *expected.Path, *returnedContent.Path)
- assert.Equal(t, *expected.Type, *returnedContent.Type)
+ case mcp.TextResourceContents:
+ textResource := getTextResourceResult(t, result)
+ assert.Equal(t, expected, textResource)
+ case mcp.BlobResourceContents:
+ blobResource := getBlobResourceResult(t, result)
+ assert.Equal(t, expected, blobResource)
case []*github.RepositoryContent:
+ // Directory content fetch returns a text result (JSON array)
+ textContent := getTextResult(t, result)
var returnedContents []*github.RepositoryContent
err = json.Unmarshal([]byte(textContent.Text), &returnedContents)
require.NoError(t, err)
@@ -181,6 +207,9 @@ func Test_GetFileContents(t *testing.T) {
assert.Equal(t, *expected[i].Path, *content.Path)
assert.Equal(t, *expected[i].Type, *content.Type)
}
+ case mcp.TextContent:
+ textContent := getErrorResult(t, result)
+ require.Equal(t, textContent, expected)
}
})
}
@@ -1528,3 +1557,449 @@ func Test_ListBranches(t *testing.T) {
})
}
}
+
+func Test_DeleteFile(t *testing.T) {
+ // Verify tool definition once
+ mockClient := github.NewClient(nil)
+ tool, _ := DeleteFile(stubGetClientFn(mockClient), translations.NullTranslationHelper)
+
+ assert.Equal(t, "delete_file", 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, "path")
+ assert.Contains(t, tool.InputSchema.Properties, "message")
+ assert.Contains(t, tool.InputSchema.Properties, "branch")
+ // SHA is no longer required since we're using Git Data API
+ assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "path", "message", "branch"})
+
+ // Setup mock objects for Git Data API
+ mockRef := &github.Reference{
+ Ref: github.Ptr("refs/heads/main"),
+ Object: &github.GitObject{
+ SHA: github.Ptr("abc123"),
+ },
+ }
+
+ mockCommit := &github.Commit{
+ SHA: github.Ptr("abc123"),
+ Tree: &github.Tree{
+ SHA: github.Ptr("def456"),
+ },
+ }
+
+ mockTree := &github.Tree{
+ SHA: github.Ptr("ghi789"),
+ }
+
+ mockNewCommit := &github.Commit{
+ SHA: github.Ptr("jkl012"),
+ Message: github.Ptr("Delete example file"),
+ HTMLURL: github.Ptr("https://github.com/owner/repo/commit/jkl012"),
+ }
+
+ tests := []struct {
+ name string
+ mockedClient *http.Client
+ requestArgs map[string]interface{}
+ expectError bool
+ expectedCommitSHA string
+ expectedErrMsg string
+ }{
+ {
+ name: "successful file deletion using Git Data API",
+ mockedClient: mock.NewMockedHTTPClient(
+ // Get branch reference
+ mock.WithRequestMatch(
+ mock.GetReposGitRefByOwnerByRepoByRef,
+ mockRef,
+ ),
+ // Get commit
+ mock.WithRequestMatch(
+ mock.GetReposGitCommitsByOwnerByRepoByCommitSha,
+ mockCommit,
+ ),
+ // Create tree
+ mock.WithRequestMatchHandler(
+ mock.PostReposGitTreesByOwnerByRepo,
+ expectRequestBody(t, map[string]interface{}{
+ "base_tree": "def456",
+ "tree": []interface{}{
+ map[string]interface{}{
+ "path": "docs/example.md",
+ "mode": "100644",
+ "type": "blob",
+ "sha": nil,
+ },
+ },
+ }).andThen(
+ mockResponse(t, http.StatusCreated, mockTree),
+ ),
+ ),
+ // Create commit
+ mock.WithRequestMatchHandler(
+ mock.PostReposGitCommitsByOwnerByRepo,
+ expectRequestBody(t, map[string]interface{}{
+ "message": "Delete example file",
+ "tree": "ghi789",
+ "parents": []interface{}{"abc123"},
+ }).andThen(
+ mockResponse(t, http.StatusCreated, mockNewCommit),
+ ),
+ ),
+ // Update reference
+ mock.WithRequestMatchHandler(
+ mock.PatchReposGitRefsByOwnerByRepoByRef,
+ expectRequestBody(t, map[string]interface{}{
+ "sha": "jkl012",
+ "force": false,
+ }).andThen(
+ mockResponse(t, http.StatusOK, &github.Reference{
+ Ref: github.Ptr("refs/heads/main"),
+ Object: &github.GitObject{
+ SHA: github.Ptr("jkl012"),
+ },
+ }),
+ ),
+ ),
+ ),
+ requestArgs: map[string]interface{}{
+ "owner": "owner",
+ "repo": "repo",
+ "path": "docs/example.md",
+ "message": "Delete example file",
+ "branch": "main",
+ },
+ expectError: false,
+ expectedCommitSHA: "jkl012",
+ },
+ {
+ name: "file deletion fails - branch not found",
+ mockedClient: mock.NewMockedHTTPClient(
+ mock.WithRequestMatchHandler(
+ mock.GetReposGitRefByOwnerByRepoByRef,
+ http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(http.StatusNotFound)
+ _, _ = w.Write([]byte(`{"message": "Reference not found"}`))
+ }),
+ ),
+ ),
+ requestArgs: map[string]interface{}{
+ "owner": "owner",
+ "repo": "repo",
+ "path": "docs/nonexistent.md",
+ "message": "Delete nonexistent file",
+ "branch": "nonexistent-branch",
+ },
+ expectError: true,
+ expectedErrMsg: "failed to get branch reference",
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ // Setup client with mock
+ client := github.NewClient(tc.mockedClient)
+ _, handler := DeleteFile(stubGetClientFn(client), translations.NullTranslationHelper)
+
+ // Create call request
+ request := createMCPRequest(tc.requestArgs)
+
+ // Call handler
+ result, err := handler(context.Background(), request)
+
+ // Verify results
+ if tc.expectError {
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), tc.expectedErrMsg)
+ return
+ }
+
+ require.NoError(t, err)
+
+ // Parse the result and get the text content if no error
+ textContent := getTextResult(t, result)
+
+ // Unmarshal and verify the result
+ var response map[string]interface{}
+ err = json.Unmarshal([]byte(textContent.Text), &response)
+ require.NoError(t, err)
+
+ // Verify the response contains the expected commit
+ commit, ok := response["commit"].(map[string]interface{})
+ require.True(t, ok)
+ commitSHA, ok := commit["sha"].(string)
+ require.True(t, ok)
+ assert.Equal(t, tc.expectedCommitSHA, commitSHA)
+ })
+ }
+}
+
+func Test_ListTags(t *testing.T) {
+ // Verify tool definition once
+ mockClient := github.NewClient(nil)
+ tool, _ := ListTags(stubGetClientFn(mockClient), translations.NullTranslationHelper)
+
+ assert.Equal(t, "list_tags", 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"})
+
+ // Setup mock tags for success case
+ mockTags := []*github.RepositoryTag{
+ {
+ Name: github.Ptr("v1.0.0"),
+ Commit: &github.Commit{
+ SHA: github.Ptr("v1.0.0-tag-sha"),
+ URL: github.Ptr("https://api.github.com/repos/owner/repo/commits/abc123"),
+ },
+ ZipballURL: github.Ptr("https://github.com/owner/repo/zipball/v1.0.0"),
+ TarballURL: github.Ptr("https://github.com/owner/repo/tarball/v1.0.0"),
+ },
+ {
+ Name: github.Ptr("v0.9.0"),
+ Commit: &github.Commit{
+ SHA: github.Ptr("v0.9.0-tag-sha"),
+ URL: github.Ptr("https://api.github.com/repos/owner/repo/commits/def456"),
+ },
+ ZipballURL: github.Ptr("https://github.com/owner/repo/zipball/v0.9.0"),
+ TarballURL: github.Ptr("https://github.com/owner/repo/tarball/v0.9.0"),
+ },
+ }
+
+ tests := []struct {
+ name string
+ mockedClient *http.Client
+ requestArgs map[string]interface{}
+ expectError bool
+ expectedTags []*github.RepositoryTag
+ expectedErrMsg string
+ }{
+ {
+ name: "successful tags list",
+ mockedClient: mock.NewMockedHTTPClient(
+ mock.WithRequestMatchHandler(
+ mock.GetReposTagsByOwnerByRepo,
+ expectPath(
+ t,
+ "/repos/owner/repo/tags",
+ ).andThen(
+ mockResponse(t, http.StatusOK, mockTags),
+ ),
+ ),
+ ),
+ requestArgs: map[string]interface{}{
+ "owner": "owner",
+ "repo": "repo",
+ },
+ expectError: false,
+ expectedTags: mockTags,
+ },
+ {
+ name: "list tags fails",
+ mockedClient: mock.NewMockedHTTPClient(
+ mock.WithRequestMatchHandler(
+ mock.GetReposTagsByOwnerByRepo,
+ http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(http.StatusInternalServerError)
+ _, _ = w.Write([]byte(`{"message": "Internal Server Error"}`))
+ }),
+ ),
+ ),
+ requestArgs: map[string]interface{}{
+ "owner": "owner",
+ "repo": "repo",
+ },
+ expectError: true,
+ expectedErrMsg: "failed to list tags",
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ // Setup client with mock
+ client := github.NewClient(tc.mockedClient)
+ _, handler := ListTags(stubGetClientFn(client), translations.NullTranslationHelper)
+
+ // Create call request
+ request := createMCPRequest(tc.requestArgs)
+
+ // Call handler
+ result, err := handler(context.Background(), request)
+
+ // Verify results
+ if tc.expectError {
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), tc.expectedErrMsg)
+ return
+ }
+
+ require.NoError(t, err)
+
+ // Parse the result and get the text content if no error
+ textContent := getTextResult(t, result)
+
+ // Parse and verify the result
+ var returnedTags []*github.RepositoryTag
+ err = json.Unmarshal([]byte(textContent.Text), &returnedTags)
+ require.NoError(t, err)
+
+ // Verify each tag
+ require.Equal(t, len(tc.expectedTags), len(returnedTags))
+ for i, expectedTag := range tc.expectedTags {
+ assert.Equal(t, *expectedTag.Name, *returnedTags[i].Name)
+ assert.Equal(t, *expectedTag.Commit.SHA, *returnedTags[i].Commit.SHA)
+ }
+ })
+ }
+}
+
+func Test_GetTag(t *testing.T) {
+ // Verify tool definition once
+ mockClient := github.NewClient(nil)
+ tool, _ := GetTag(stubGetClientFn(mockClient), translations.NullTranslationHelper)
+
+ assert.Equal(t, "get_tag", tool.Name)
+ assert.NotEmpty(t, tool.Description)
+ assert.Contains(t, tool.InputSchema.Properties, "owner")
+ assert.Contains(t, tool.InputSchema.Properties, "repo")
+ assert.Contains(t, tool.InputSchema.Properties, "tag")
+ assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "tag"})
+
+ mockTagRef := &github.Reference{
+ Ref: github.Ptr("refs/tags/v1.0.0"),
+ Object: &github.GitObject{
+ SHA: github.Ptr("v1.0.0-tag-sha"),
+ },
+ }
+
+ mockTagObj := &github.Tag{
+ SHA: github.Ptr("v1.0.0-tag-sha"),
+ Tag: github.Ptr("v1.0.0"),
+ Message: github.Ptr("Release v1.0.0"),
+ Object: &github.GitObject{
+ Type: github.Ptr("commit"),
+ SHA: github.Ptr("abc123"),
+ },
+ }
+
+ tests := []struct {
+ name string
+ mockedClient *http.Client
+ requestArgs map[string]interface{}
+ expectError bool
+ expectedTag *github.Tag
+ expectedErrMsg string
+ }{
+ {
+ name: "successful tag retrieval",
+ mockedClient: mock.NewMockedHTTPClient(
+ mock.WithRequestMatchHandler(
+ mock.GetReposGitRefByOwnerByRepoByRef,
+ expectPath(
+ t,
+ "/repos/owner/repo/git/ref/tags/v1.0.0",
+ ).andThen(
+ mockResponse(t, http.StatusOK, mockTagRef),
+ ),
+ ),
+ mock.WithRequestMatchHandler(
+ mock.GetReposGitTagsByOwnerByRepoByTagSha,
+ expectPath(
+ t,
+ "/repos/owner/repo/git/tags/v1.0.0-tag-sha",
+ ).andThen(
+ mockResponse(t, http.StatusOK, mockTagObj),
+ ),
+ ),
+ ),
+ requestArgs: map[string]interface{}{
+ "owner": "owner",
+ "repo": "repo",
+ "tag": "v1.0.0",
+ },
+ expectError: false,
+ expectedTag: mockTagObj,
+ },
+ {
+ name: "tag reference not found",
+ mockedClient: mock.NewMockedHTTPClient(
+ mock.WithRequestMatchHandler(
+ mock.GetReposGitRefByOwnerByRepoByRef,
+ http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(http.StatusNotFound)
+ _, _ = w.Write([]byte(`{"message": "Reference does not exist"}`))
+ }),
+ ),
+ ),
+ requestArgs: map[string]interface{}{
+ "owner": "owner",
+ "repo": "repo",
+ "tag": "v1.0.0",
+ },
+ expectError: true,
+ expectedErrMsg: "failed to get tag reference",
+ },
+ {
+ name: "tag object not found",
+ mockedClient: mock.NewMockedHTTPClient(
+ mock.WithRequestMatch(
+ mock.GetReposGitRefByOwnerByRepoByRef,
+ mockTagRef,
+ ),
+ mock.WithRequestMatchHandler(
+ mock.GetReposGitTagsByOwnerByRepoByTagSha,
+ http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(http.StatusNotFound)
+ _, _ = w.Write([]byte(`{"message": "Tag object does not exist"}`))
+ }),
+ ),
+ ),
+ requestArgs: map[string]interface{}{
+ "owner": "owner",
+ "repo": "repo",
+ "tag": "v1.0.0",
+ },
+ expectError: true,
+ expectedErrMsg: "failed to get tag object",
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ // Setup client with mock
+ client := github.NewClient(tc.mockedClient)
+ _, handler := GetTag(stubGetClientFn(client), translations.NullTranslationHelper)
+
+ // Create call request
+ request := createMCPRequest(tc.requestArgs)
+
+ // Call handler
+ result, err := handler(context.Background(), request)
+
+ // Verify results
+ if tc.expectError {
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), tc.expectedErrMsg)
+ return
+ }
+
+ require.NoError(t, err)
+
+ // Parse the result and get the text content if no error
+ textContent := getTextResult(t, result)
+
+ // Parse and verify the result
+ var returnedTag github.Tag
+ err = json.Unmarshal([]byte(textContent.Text), &returnedTag)
+ require.NoError(t, err)
+
+ assert.Equal(t, *tc.expectedTag.SHA, *returnedTag.SHA)
+ assert.Equal(t, *tc.expectedTag.Tag, *returnedTag.Tag)
+ assert.Equal(t, *tc.expectedTag.Message, *returnedTag.Message)
+ assert.Equal(t, *tc.expectedTag.Object.Type, *returnedTag.Object.Type)
+ assert.Equal(t, *tc.expectedTag.Object.SHA, *returnedTag.Object.SHA)
+ })
+ }
+}
diff --git a/pkg/github/repository_resource.go b/pkg/github/repository_resource.go
index 949157f55..fd2a04f89 100644
--- a/pkg/github/repository_resource.go
+++ b/pkg/github/repository_resource.go
@@ -9,61 +9,63 @@ import (
"mime"
"net/http"
"path/filepath"
+ "strconv"
"strings"
+ "github.com/github/github-mcp-server/pkg/raw"
"github.com/github/github-mcp-server/pkg/translations"
- "github.com/google/go-github/v69/github"
+ "github.com/google/go-github/v72/github"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
)
// GetRepositoryResourceContent defines the resource template and handler for getting repository content.
-func GetRepositoryResourceContent(getClient GetClientFn, t translations.TranslationHelperFunc) (mcp.ResourceTemplate, server.ResourceTemplateHandlerFunc) {
+func GetRepositoryResourceContent(getClient GetClientFn, getRawClient raw.GetRawClientFn, t translations.TranslationHelperFunc) (mcp.ResourceTemplate, server.ResourceTemplateHandlerFunc) {
return mcp.NewResourceTemplate(
"repo://{owner}/{repo}/contents{/path*}", // Resource template
t("RESOURCE_REPOSITORY_CONTENT_DESCRIPTION", "Repository Content"),
),
- RepositoryResourceContentsHandler(getClient)
+ RepositoryResourceContentsHandler(getClient, getRawClient)
}
// GetRepositoryResourceBranchContent defines the resource template and handler for getting repository content for a branch.
-func GetRepositoryResourceBranchContent(getClient GetClientFn, t translations.TranslationHelperFunc) (mcp.ResourceTemplate, server.ResourceTemplateHandlerFunc) {
+func GetRepositoryResourceBranchContent(getClient GetClientFn, getRawClient raw.GetRawClientFn, t translations.TranslationHelperFunc) (mcp.ResourceTemplate, server.ResourceTemplateHandlerFunc) {
return mcp.NewResourceTemplate(
"repo://{owner}/{repo}/refs/heads/{branch}/contents{/path*}", // Resource template
t("RESOURCE_REPOSITORY_CONTENT_BRANCH_DESCRIPTION", "Repository Content for specific branch"),
),
- RepositoryResourceContentsHandler(getClient)
+ RepositoryResourceContentsHandler(getClient, getRawClient)
}
// GetRepositoryResourceCommitContent defines the resource template and handler for getting repository content for a commit.
-func GetRepositoryResourceCommitContent(getClient GetClientFn, t translations.TranslationHelperFunc) (mcp.ResourceTemplate, server.ResourceTemplateHandlerFunc) {
+func GetRepositoryResourceCommitContent(getClient GetClientFn, getRawClient raw.GetRawClientFn, t translations.TranslationHelperFunc) (mcp.ResourceTemplate, server.ResourceTemplateHandlerFunc) {
return mcp.NewResourceTemplate(
"repo://{owner}/{repo}/sha/{sha}/contents{/path*}", // Resource template
t("RESOURCE_REPOSITORY_CONTENT_COMMIT_DESCRIPTION", "Repository Content for specific commit"),
),
- RepositoryResourceContentsHandler(getClient)
+ RepositoryResourceContentsHandler(getClient, getRawClient)
}
// GetRepositoryResourceTagContent defines the resource template and handler for getting repository content for a tag.
-func GetRepositoryResourceTagContent(getClient GetClientFn, t translations.TranslationHelperFunc) (mcp.ResourceTemplate, server.ResourceTemplateHandlerFunc) {
+func GetRepositoryResourceTagContent(getClient GetClientFn, getRawClient raw.GetRawClientFn, t translations.TranslationHelperFunc) (mcp.ResourceTemplate, server.ResourceTemplateHandlerFunc) {
return mcp.NewResourceTemplate(
"repo://{owner}/{repo}/refs/tags/{tag}/contents{/path*}", // Resource template
t("RESOURCE_REPOSITORY_CONTENT_TAG_DESCRIPTION", "Repository Content for specific tag"),
),
- RepositoryResourceContentsHandler(getClient)
+ RepositoryResourceContentsHandler(getClient, getRawClient)
}
// GetRepositoryResourcePrContent defines the resource template and handler for getting repository content for a pull request.
-func GetRepositoryResourcePrContent(getClient GetClientFn, t translations.TranslationHelperFunc) (mcp.ResourceTemplate, server.ResourceTemplateHandlerFunc) {
+func GetRepositoryResourcePrContent(getClient GetClientFn, getRawClient raw.GetRawClientFn, t translations.TranslationHelperFunc) (mcp.ResourceTemplate, server.ResourceTemplateHandlerFunc) {
return mcp.NewResourceTemplate(
"repo://{owner}/{repo}/refs/pull/{prNumber}/head/contents{/path*}", // Resource template
t("RESOURCE_REPOSITORY_CONTENT_PR_DESCRIPTION", "Repository Content for specific pull request"),
),
- RepositoryResourceContentsHandler(getClient)
+ RepositoryResourceContentsHandler(getClient, getRawClient)
}
// RepositoryResourceContentsHandler returns a handler function for repository content requests.
-func RepositoryResourceContentsHandler(getClient GetClientFn) func(ctx context.Context, request mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) {
+func RepositoryResourceContentsHandler(getClient GetClientFn, getRawClient raw.GetRawClientFn) func(ctx context.Context, request mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) {
return func(ctx context.Context, request mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) {
// the matcher will give []string with one element
// https://github.com/mark3labs/mcp-go/pull/54
@@ -87,120 +89,104 @@ func RepositoryResourceContentsHandler(getClient GetClientFn) func(ctx context.C
}
opts := &github.RepositoryContentGetOptions{}
+ rawOpts := &raw.RawContentOpts{}
sha, ok := request.Params.Arguments["sha"].([]string)
if ok && len(sha) > 0 {
opts.Ref = sha[0]
+ rawOpts.SHA = sha[0]
}
branch, ok := request.Params.Arguments["branch"].([]string)
if ok && len(branch) > 0 {
opts.Ref = "refs/heads/" + branch[0]
+ rawOpts.Ref = "refs/heads/" + branch[0]
}
tag, ok := request.Params.Arguments["tag"].([]string)
if ok && len(tag) > 0 {
opts.Ref = "refs/tags/" + tag[0]
+ rawOpts.Ref = "refs/tags/" + tag[0]
}
prNumber, ok := request.Params.Arguments["prNumber"].([]string)
if ok && len(prNumber) > 0 {
- opts.Ref = "refs/pull/" + prNumber[0] + "/head"
+ // 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[0])
+ 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()
+ rawOpts.SHA = sha
+ opts.Ref = sha
}
-
- client, err := getClient(ctx)
- if err != nil {
- return nil, fmt.Errorf("failed to get GitHub client: %w", err)
+ // if it's a directory
+ if path == "" || strings.HasSuffix(path, "/") {
+ return nil, fmt.Errorf("directories are not supported: %s", path)
}
- fileContent, directoryContent, _, err := client.Repositories.GetContents(ctx, owner, repo, path, opts)
+ rawClient, err := getRawClient(ctx)
+
if err != nil {
- return nil, err
+ return nil, fmt.Errorf("failed to get GitHub raw content client: %w", err)
}
- if directoryContent != nil {
- var resources []mcp.ResourceContents
- for _, entry := range directoryContent {
- mimeType := "text/directory"
- if entry.GetType() == "file" {
- // this is system dependent, and a best guess
- ext := filepath.Ext(entry.GetName())
- mimeType = mime.TypeByExtension(ext)
- if ext == ".md" {
- mimeType = "text/markdown"
- }
- }
- resources = append(resources, mcp.TextResourceContents{
- URI: entry.GetHTMLURL(),
- MIMEType: mimeType,
- Text: entry.GetName(),
- })
-
+ resp, err := rawClient.GetRawContent(ctx, owner, repo, path, rawOpts)
+ defer func() {
+ _ = resp.Body.Close()
+ }()
+ // If the raw content is not found, we will fall back to the GitHub API (in case it is a directory)
+ switch {
+ case err != nil:
+ return nil, fmt.Errorf("failed to get raw content: %w", err)
+ case resp.StatusCode == http.StatusOK:
+ ext := filepath.Ext(path)
+ mimeType := resp.Header.Get("Content-Type")
+ if ext == ".md" {
+ mimeType = "text/markdown"
+ } else if mimeType == "" {
+ mimeType = mime.TypeByExtension(ext)
}
- return resources, nil
- }
- if fileContent != nil {
- if fileContent.Content != nil {
- // download the file content from fileContent.GetDownloadURL() and use the content-type header to determine the MIME type
- // and return the content as a blob unless it is a text file, where you can return the content as text
- req, err := http.NewRequest("GET", fileContent.GetDownloadURL(), nil)
- if err != nil {
- return nil, fmt.Errorf("failed to create request: %w", err)
- }
-
- resp, err := client.Client().Do(req)
- if err != nil {
- return nil, fmt.Errorf("failed to send request: %w", err)
- }
- defer func() { _ = resp.Body.Close() }()
-
- if resp.StatusCode != http.StatusOK {
- body, err := io.ReadAll(resp.Body)
- if err != nil {
- return nil, fmt.Errorf("failed to read response body: %w", err)
- }
- return nil, fmt.Errorf("failed to fetch file content: %s", string(body))
- }
-
- ext := filepath.Ext(fileContent.GetName())
- mimeType := resp.Header.Get("Content-Type")
- if ext == ".md" {
- mimeType = "text/markdown"
- } else if mimeType == "" {
- // backstop to the file extension if the content type is not set
- mimeType = mime.TypeByExtension(filepath.Ext(fileContent.GetName()))
- }
-
- // if the content is a string, return it as text
- if strings.HasPrefix(mimeType, "text") {
- content, err := io.ReadAll(resp.Body)
- if err != nil {
- return nil, fmt.Errorf("failed to parse the response body: %w", err)
- }
-
- return []mcp.ResourceContents{
- mcp.TextResourceContents{
- URI: request.Params.URI,
- MIMEType: mimeType,
- Text: string(content),
- },
- }, nil
- }
- // otherwise, read the content and encode it as base64
- decodedContent, err := io.ReadAll(resp.Body)
- if err != nil {
- return nil, fmt.Errorf("failed to parse the response body: %w", err)
- }
+ content, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, fmt.Errorf("failed to read file content: %w", err)
+ }
+ switch {
+ case strings.HasPrefix(mimeType, "text"), strings.HasPrefix(mimeType, "application"):
+ return []mcp.ResourceContents{
+ mcp.TextResourceContents{
+ URI: request.Params.URI,
+ MIMEType: mimeType,
+ Text: string(content),
+ },
+ }, nil
+ default:
return []mcp.ResourceContents{
mcp.BlobResourceContents{
URI: request.Params.URI,
MIMEType: mimeType,
- Blob: base64.StdEncoding.EncodeToString(decodedContent), // Encode content as Base64
+ Blob: base64.StdEncoding.EncodeToString(content),
},
}, nil
}
+ case resp.StatusCode != http.StatusNotFound:
+ // If we got a response but it is not 200 OK, we return an error
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, fmt.Errorf("failed to read response body: %w", err)
+ }
+ return nil, fmt.Errorf("failed to fetch raw content: %s", string(body))
+ default:
+ // This should be unreachable because GetContents should return an error if neither file nor directory content is found.
+ return nil, errors.New("404 Not Found")
}
-
- return nil, errors.New("no repository resource content found")
}
}
diff --git a/pkg/github/repository_resource_test.go b/pkg/github/repository_resource_test.go
index ffd14be32..0e9f018e7 100644
--- a/pkg/github/repository_resource_test.go
+++ b/pkg/github/repository_resource_test.go
@@ -3,105 +3,37 @@ package github
import (
"context"
"net/http"
+ "net/url"
"testing"
+ "github.com/github/github-mcp-server/pkg/raw"
"github.com/github/github-mcp-server/pkg/translations"
- "github.com/google/go-github/v69/github"
+ "github.com/google/go-github/v72/github"
"github.com/mark3labs/mcp-go/mcp"
"github.com/migueleliasweb/go-github-mock/src/mock"
"github.com/stretchr/testify/require"
)
-var GetRawReposContentsByOwnerByRepoByPath mock.EndpointPattern = mock.EndpointPattern{
- Pattern: "/{owner}/{repo}/main/{path:.+}",
- Method: "GET",
-}
-
func Test_repositoryResourceContentsHandler(t *testing.T) {
- mockDirContent := []*github.RepositoryContent{
- {
- Type: github.Ptr("file"),
- Name: github.Ptr("README.md"),
- Path: github.Ptr("README.md"),
- SHA: github.Ptr("abc123"),
- Size: github.Ptr(42),
- HTMLURL: github.Ptr("https://github.com/owner/repo/blob/main/README.md"),
- DownloadURL: github.Ptr("https://raw.githubusercontent.com/owner/repo/main/README.md"),
- },
- {
- Type: github.Ptr("dir"),
- Name: github.Ptr("src"),
- Path: github.Ptr("src"),
- SHA: github.Ptr("def456"),
- HTMLURL: github.Ptr("https://github.com/owner/repo/tree/main/src"),
- DownloadURL: github.Ptr("https://raw.githubusercontent.com/owner/repo/main/src"),
- },
- }
- expectedDirContent := []mcp.TextResourceContents{
- {
- URI: "https://github.com/owner/repo/blob/main/README.md",
- MIMEType: "text/markdown",
- Text: "README.md",
- },
- {
- URI: "https://github.com/owner/repo/tree/main/src",
- MIMEType: "text/directory",
- Text: "src",
- },
- }
-
- mockTextContent := &github.RepositoryContent{
- Type: github.Ptr("file"),
- Name: github.Ptr("README.md"),
- Path: github.Ptr("README.md"),
- Content: github.Ptr("# Test Repository\n\nThis is a test repository."),
- SHA: github.Ptr("abc123"),
- Size: github.Ptr(42),
- HTMLURL: github.Ptr("https://github.com/owner/repo/blob/main/README.md"),
- DownloadURL: github.Ptr("https://raw.githubusercontent.com/owner/repo/main/README.md"),
- }
-
- mockFileContent := &github.RepositoryContent{
- Type: github.Ptr("file"),
- Name: github.Ptr("data.png"),
- Path: github.Ptr("data.png"),
- Content: github.Ptr("IyBUZXN0IFJlcG9zaXRvcnkKClRoaXMgaXMgYSB0ZXN0IHJlcG9zaXRvcnku"), // Base64 encoded "# Test Repository\n\nThis is a test repository."
- SHA: github.Ptr("abc123"),
- Size: github.Ptr(42),
- HTMLURL: github.Ptr("https://github.com/owner/repo/blob/main/data.png"),
- DownloadURL: github.Ptr("https://raw.githubusercontent.com/owner/repo/main/data.png"),
- }
-
- expectedFileContent := []mcp.BlobResourceContents{
- {
- Blob: "IyBUZXN0IFJlcG9zaXRvcnkKClRoaXMgaXMgYSB0ZXN0IHJlcG9zaXRvcnku",
- MIMEType: "image/png",
- URI: "",
- },
- }
-
- expectedTextContent := []mcp.TextResourceContents{
- {
- Text: "# Test Repository\n\nThis is a test repository.",
- MIMEType: "text/markdown",
- URI: "",
- },
- }
-
+ base, _ := url.Parse("https://raw.example.com/")
tests := []struct {
name string
mockedClient *http.Client
requestArgs map[string]any
expectError string
expectedResult any
- expectedErrMsg string
}{
{
name: "missing owner",
mockedClient: mock.NewMockedHTTPClient(
- mock.WithRequestMatch(
- mock.GetReposContentsByOwnerByRepoByPath,
- mockFileContent,
+ mock.WithRequestMatchHandler(
+ raw.GetRawReposContentsByOwnerByRepoByPath,
+ http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.Header().Set("Content-Type", "image/png")
+ // as this is given as a png, it will return the content as a blob
+ _, err := w.Write([]byte("# Test Repository\n\nThis is a test repository."))
+ require.NoError(t, err)
+ }),
),
),
requestArgs: map[string]any{},
@@ -110,9 +42,14 @@ func Test_repositoryResourceContentsHandler(t *testing.T) {
{
name: "missing repo",
mockedClient: mock.NewMockedHTTPClient(
- mock.WithRequestMatch(
- mock.GetReposContentsByOwnerByRepoByPath,
- mockFileContent,
+ mock.WithRequestMatchHandler(
+ raw.GetRawReposContentsByOwnerByRepoByBranchByPath,
+ http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.Header().Set("Content-Type", "image/png")
+ // as this is given as a png, it will return the content as a blob
+ _, err := w.Write([]byte("# Test Repository\n\nThis is a test repository."))
+ require.NoError(t, err)
+ }),
),
),
requestArgs: map[string]any{
@@ -123,38 +60,59 @@ func Test_repositoryResourceContentsHandler(t *testing.T) {
{
name: "successful blob content fetch",
mockedClient: mock.NewMockedHTTPClient(
- mock.WithRequestMatch(
- mock.GetReposContentsByOwnerByRepoByPath,
- mockFileContent,
- ),
mock.WithRequestMatchHandler(
- GetRawReposContentsByOwnerByRepoByPath,
+ raw.GetRawReposContentsByOwnerByRepoByPath,
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "image/png")
- // as this is given as a png, it will return the content as a blob
_, err := w.Write([]byte("# Test Repository\n\nThis is a test repository."))
require.NoError(t, err)
}),
),
),
requestArgs: map[string]any{
- "owner": []string{"owner"},
- "repo": []string{"repo"},
- "path": []string{"data.png"},
- "branch": []string{"main"},
+ "owner": []string{"owner"},
+ "repo": []string{"repo"},
+ "path": []string{"data.png"},
},
- expectedResult: expectedFileContent,
+ expectedResult: []mcp.BlobResourceContents{{
+ Blob: "IyBUZXN0IFJlcG9zaXRvcnkKClRoaXMgaXMgYSB0ZXN0IHJlcG9zaXRvcnku",
+ MIMEType: "image/png",
+ URI: "",
+ }},
},
{
- name: "successful text content fetch",
+ name: "successful text content fetch (HEAD)",
mockedClient: mock.NewMockedHTTPClient(
- mock.WithRequestMatch(
- mock.GetReposContentsByOwnerByRepoByPath,
- mockTextContent,
+ mock.WithRequestMatchHandler(
+ raw.GetRawReposContentsByOwnerByRepoByPath,
+ http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.Header().Set("Content-Type", "text/markdown")
+ _, err := w.Write([]byte("# Test Repository\n\nThis is a test repository."))
+ require.NoError(t, err)
+ }),
),
- mock.WithRequestMatch(
- GetRawReposContentsByOwnerByRepoByPath,
- []byte("# Test Repository\n\nThis is a test repository."),
+ ),
+ requestArgs: map[string]any{
+ "owner": []string{"owner"},
+ "repo": []string{"repo"},
+ "path": []string{"README.md"},
+ },
+ expectedResult: []mcp.TextResourceContents{{
+ Text: "# Test Repository\n\nThis is a test repository.",
+ MIMEType: "text/markdown",
+ URI: "",
+ }},
+ },
+ {
+ name: "successful text content fetch (branch)",
+ mockedClient: mock.NewMockedHTTPClient(
+ mock.WithRequestMatchHandler(
+ raw.GetRawReposContentsByOwnerByRepoByBranchByPath,
+ http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.Header().Set("Content-Type", "text/markdown")
+ _, err := w.Write([]byte("# Test Repository\n\nThis is a test repository."))
+ require.NoError(t, err)
+ }),
),
),
requestArgs: map[string]any{
@@ -163,52 +121,91 @@ func Test_repositoryResourceContentsHandler(t *testing.T) {
"path": []string{"README.md"},
"branch": []string{"main"},
},
- expectedResult: expectedTextContent,
+ expectedResult: []mcp.TextResourceContents{{
+ Text: "# Test Repository\n\nThis is a test repository.",
+ MIMEType: "text/markdown",
+ URI: "",
+ }},
},
{
- name: "successful directory content fetch",
+ name: "successful text content fetch (tag)",
mockedClient: mock.NewMockedHTTPClient(
- mock.WithRequestMatch(
- mock.GetReposContentsByOwnerByRepoByPath,
- mockDirContent,
+ mock.WithRequestMatchHandler(
+ raw.GetRawReposContentsByOwnerByRepoByTagByPath,
+ http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.Header().Set("Content-Type", "text/markdown")
+ _, err := w.Write([]byte("# Test Repository\n\nThis is a test repository."))
+ require.NoError(t, err)
+ }),
),
),
requestArgs: map[string]any{
"owner": []string{"owner"},
"repo": []string{"repo"},
- "path": []string{"src"},
+ "path": []string{"README.md"},
+ "tag": []string{"v1.0.0"},
},
- expectedResult: expectedDirContent,
+ expectedResult: []mcp.TextResourceContents{{
+ Text: "# Test Repository\n\nThis is a test repository.",
+ MIMEType: "text/markdown",
+ URI: "",
+ }},
},
{
- name: "no data",
+ name: "successful text content fetch (sha)",
mockedClient: mock.NewMockedHTTPClient(
- mock.WithRequestMatch(
- mock.GetReposContentsByOwnerByRepoByPath,
+ mock.WithRequestMatchHandler(
+ raw.GetRawReposContentsByOwnerByRepoBySHAByPath,
+ http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.Header().Set("Content-Type", "text/markdown")
+ _, err := w.Write([]byte("# Test Repository\n\nThis is a test repository."))
+ require.NoError(t, err)
+ }),
),
),
requestArgs: map[string]any{
"owner": []string{"owner"},
"repo": []string{"repo"},
- "path": []string{"src"},
+ "path": []string{"README.md"},
+ "sha": []string{"abc123"},
},
- expectedResult: nil,
- expectError: "no repository resource content found",
+ expectedResult: []mcp.TextResourceContents{{
+ Text: "# Test Repository\n\nThis is a test repository.",
+ MIMEType: "text/markdown",
+ URI: "",
+ }},
},
{
- name: "empty data",
+ name: "successful text content fetch (pr)",
mockedClient: mock.NewMockedHTTPClient(
- mock.WithRequestMatch(
- mock.GetReposContentsByOwnerByRepoByPath,
- []*github.RepositoryContent{},
+ mock.WithRequestMatchHandler(
+ mock.GetReposPullsByOwnerByRepoByPullNumber,
+ http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ _, err := w.Write([]byte(`{"head": {"sha": "abc123"}}`))
+ require.NoError(t, err)
+ }),
+ ),
+ mock.WithRequestMatchHandler(
+ raw.GetRawReposContentsByOwnerByRepoBySHAByPath,
+ http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.Header().Set("Content-Type", "text/markdown")
+ _, err := w.Write([]byte("# Test Repository\n\nThis is a test repository."))
+ require.NoError(t, err)
+ }),
),
),
requestArgs: map[string]any{
- "owner": []string{"owner"},
- "repo": []string{"repo"},
- "path": []string{"src"},
+ "owner": []string{"owner"},
+ "repo": []string{"repo"},
+ "path": []string{"README.md"},
+ "prNumber": []string{"42"},
},
- expectedResult: nil,
+ expectedResult: []mcp.TextResourceContents{{
+ Text: "# Test Repository\n\nThis is a test repository.",
+ MIMEType: "text/markdown",
+ URI: "",
+ }},
},
{
name: "content fetch fails",
@@ -234,7 +231,8 @@ func Test_repositoryResourceContentsHandler(t *testing.T) {
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
client := github.NewClient(tc.mockedClient)
- handler := RepositoryResourceContentsHandler((stubGetClientFn(client)))
+ mockRawClient := raw.NewClient(client, base)
+ handler := RepositoryResourceContentsHandler((stubGetClientFn(client)), stubGetRawClientFn(mockRawClient))
request := mcp.ReadResourceRequest{
Params: struct {
@@ -248,7 +246,7 @@ func Test_repositoryResourceContentsHandler(t *testing.T) {
resp, err := handler(context.TODO(), request)
if tc.expectError != "" {
- require.ErrorContains(t, err, tc.expectedErrMsg)
+ require.ErrorContains(t, err, tc.expectError)
return
}
@@ -259,25 +257,24 @@ func Test_repositoryResourceContentsHandler(t *testing.T) {
}
func Test_GetRepositoryResourceContent(t *testing.T) {
- tmpl, _ := GetRepositoryResourceContent(nil, translations.NullTranslationHelper)
+ mockRawClient := raw.NewClient(github.NewClient(nil), &url.URL{})
+ tmpl, _ := GetRepositoryResourceContent(nil, stubGetRawClientFn(mockRawClient), translations.NullTranslationHelper)
require.Equal(t, "repo://{owner}/{repo}/contents{/path*}", tmpl.URITemplate.Raw())
}
func Test_GetRepositoryResourceBranchContent(t *testing.T) {
- tmpl, _ := GetRepositoryResourceBranchContent(nil, translations.NullTranslationHelper)
+ mockRawClient := raw.NewClient(github.NewClient(nil), &url.URL{})
+ tmpl, _ := GetRepositoryResourceBranchContent(nil, stubGetRawClientFn(mockRawClient), translations.NullTranslationHelper)
require.Equal(t, "repo://{owner}/{repo}/refs/heads/{branch}/contents{/path*}", tmpl.URITemplate.Raw())
}
func Test_GetRepositoryResourceCommitContent(t *testing.T) {
- tmpl, _ := GetRepositoryResourceCommitContent(nil, translations.NullTranslationHelper)
+ mockRawClient := raw.NewClient(github.NewClient(nil), &url.URL{})
+ tmpl, _ := GetRepositoryResourceCommitContent(nil, stubGetRawClientFn(mockRawClient), translations.NullTranslationHelper)
require.Equal(t, "repo://{owner}/{repo}/sha/{sha}/contents{/path*}", tmpl.URITemplate.Raw())
}
func Test_GetRepositoryResourceTagContent(t *testing.T) {
- tmpl, _ := GetRepositoryResourceTagContent(nil, translations.NullTranslationHelper)
+ mockRawClient := raw.NewClient(github.NewClient(nil), &url.URL{})
+ tmpl, _ := GetRepositoryResourceTagContent(nil, stubGetRawClientFn(mockRawClient), translations.NullTranslationHelper)
require.Equal(t, "repo://{owner}/{repo}/refs/tags/{tag}/contents{/path*}", tmpl.URITemplate.Raw())
}
-
-func Test_GetRepositoryResourcePrContent(t *testing.T) {
- tmpl, _ := GetRepositoryResourcePrContent(nil, translations.NullTranslationHelper)
- require.Equal(t, "repo://{owner}/{repo}/refs/pull/{prNumber}/head/contents{/path*}", tmpl.URITemplate.Raw())
-}
diff --git a/pkg/github/resources.go b/pkg/github/resources.go
deleted file mode 100644
index 774261e94..000000000
--- a/pkg/github/resources.go
+++ /dev/null
@@ -1,14 +0,0 @@
-package github
-
-import (
- "github.com/github/github-mcp-server/pkg/translations"
- "github.com/mark3labs/mcp-go/server"
-)
-
-func RegisterResources(s *server.MCPServer, getClient GetClientFn, t translations.TranslationHelperFunc) {
- s.AddResourceTemplate(GetRepositoryResourceContent(getClient, t))
- s.AddResourceTemplate(GetRepositoryResourceBranchContent(getClient, t))
- s.AddResourceTemplate(GetRepositoryResourceCommitContent(getClient, t))
- s.AddResourceTemplate(GetRepositoryResourceTagContent(getClient, t))
- s.AddResourceTemplate(GetRepositoryResourcePrContent(getClient, t))
-}
diff --git a/pkg/github/search.go b/pkg/github/search.go
index dc85c177e..157675c15 100644
--- a/pkg/github/search.go
+++ b/pkg/github/search.go
@@ -7,7 +7,7 @@ import (
"io"
"github.com/github/github-mcp-server/pkg/translations"
- "github.com/google/go-github/v69/github"
+ "github.com/google/go-github/v72/github"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
)
@@ -16,6 +16,10 @@ import (
func SearchRepositories(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
return mcp.NewTool("search_repositories",
mcp.WithDescription(t("TOOL_SEARCH_REPOSITORIES_DESCRIPTION", "Search for GitHub repositories")),
+ mcp.WithToolAnnotation(mcp.ToolAnnotation{
+ Title: t("TOOL_SEARCH_REPOSITORIES_USER_TITLE", "Search repositories"),
+ ReadOnlyHint: ToBoolPtr(true),
+ }),
mcp.WithString("query",
mcp.Required(),
mcp.Description("Search query"),
@@ -23,7 +27,7 @@ func SearchRepositories(getClient GetClientFn, t translations.TranslationHelperF
WithPagination(),
),
func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
- query, err := requiredParam[string](request, "query")
+ query, err := RequiredParam[string](request, "query")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
@@ -70,6 +74,10 @@ func SearchRepositories(getClient GetClientFn, t translations.TranslationHelperF
func SearchCode(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
return mcp.NewTool("search_code",
mcp.WithDescription(t("TOOL_SEARCH_CODE_DESCRIPTION", "Search for code across GitHub repositories")),
+ mcp.WithToolAnnotation(mcp.ToolAnnotation{
+ Title: t("TOOL_SEARCH_CODE_USER_TITLE", "Search code"),
+ ReadOnlyHint: ToBoolPtr(true),
+ }),
mcp.WithString("q",
mcp.Required(),
mcp.Description("Search query using GitHub code search syntax"),
@@ -84,7 +92,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, "q")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
@@ -138,10 +146,27 @@ func SearchCode(getClient GetClientFn, t translations.TranslationHelperFunc) (to
}
}
+type MinimalUser struct {
+ Login string `json:"login"`
+ ID int64 `json:"id,omitempty"`
+ ProfileURL string `json:"profile_url,omitempty"`
+ AvatarURL string `json:"avatar_url,omitempty"`
+}
+
+type MinimalSearchUsersResult struct {
+ TotalCount int `json:"total_count"`
+ IncompleteResults bool `json:"incomplete_results"`
+ Items []MinimalUser `json:"items"`
+}
+
// SearchUsers creates a tool to search for GitHub users.
func SearchUsers(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
return mcp.NewTool("search_users",
mcp.WithDescription(t("TOOL_SEARCH_USERS_DESCRIPTION", "Search for GitHub users")),
+ mcp.WithToolAnnotation(mcp.ToolAnnotation{
+ Title: t("TOOL_SEARCH_USERS_USER_TITLE", "Search users"),
+ ReadOnlyHint: ToBoolPtr(true),
+ }),
mcp.WithString("q",
mcp.Required(),
mcp.Description("Search query using GitHub users search syntax"),
@@ -157,7 +182,7 @@ func SearchUsers(getClient GetClientFn, t translations.TranslationHelperFunc) (t
WithPagination(),
),
func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
- query, err := requiredParam[string](request, "q")
+ query, err := RequiredParam[string](request, "q")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
@@ -188,7 +213,7 @@ func SearchUsers(getClient GetClientFn, t translations.TranslationHelperFunc) (t
return nil, fmt.Errorf("failed to get GitHub client: %w", err)
}
- result, resp, err := client.Search.Users(ctx, query, opts)
+ result, resp, err := client.Search.Users(ctx, "type:user "+query, opts)
if err != nil {
return nil, fmt.Errorf("failed to search users: %w", err)
}
@@ -202,11 +227,28 @@ func SearchUsers(getClient GetClientFn, t translations.TranslationHelperFunc) (t
return mcp.NewToolResultError(fmt.Sprintf("failed to search users: %s", string(body))), nil
}
- r, err := json.Marshal(result)
+ minimalUsers := make([]MinimalUser, 0, len(result.Users))
+ for _, user := range result.Users {
+ mu := MinimalUser{
+ Login: user.GetLogin(),
+ ID: user.GetID(),
+ ProfileURL: user.GetHTMLURL(),
+ AvatarURL: user.GetAvatarURL(),
+ }
+
+ minimalUsers = append(minimalUsers, mu)
+ }
+
+ minimalResp := MinimalSearchUsersResult{
+ TotalCount: result.GetTotal(),
+ IncompleteResults: result.GetIncompleteResults(),
+ Items: minimalUsers,
+ }
+
+ r, err := json.Marshal(minimalResp)
if err != nil {
return nil, fmt.Errorf("failed to marshal response: %w", err)
}
-
return mcp.NewToolResultText(string(r)), nil
}
}
diff --git a/pkg/github/search_test.go b/pkg/github/search_test.go
index b61518e47..62645e91d 100644
--- a/pkg/github/search_test.go
+++ b/pkg/github/search_test.go
@@ -7,7 +7,7 @@ import (
"testing"
"github.com/github/github-mcp-server/pkg/translations"
- "github.com/google/go-github/v69/github"
+ "github.com/google/go-github/v72/github"
"github.com/migueleliasweb/go-github-mock/src/mock"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -335,9 +335,6 @@ func Test_SearchUsers(t *testing.T) {
ID: github.Ptr(int64(1001)),
HTMLURL: github.Ptr("https://github.com/user1"),
AvatarURL: github.Ptr("https://avatars.githubusercontent.com/u/1001"),
- Type: github.Ptr("User"),
- Followers: github.Ptr(100),
- Following: github.Ptr(50),
},
{
Login: github.Ptr("user2"),
@@ -345,8 +342,6 @@ func Test_SearchUsers(t *testing.T) {
HTMLURL: github.Ptr("https://github.com/user2"),
AvatarURL: github.Ptr("https://avatars.githubusercontent.com/u/1002"),
Type: github.Ptr("User"),
- Followers: github.Ptr(200),
- Following: github.Ptr(75),
},
},
}
@@ -365,7 +360,7 @@ func Test_SearchUsers(t *testing.T) {
mock.WithRequestMatchHandler(
mock.GetSearchUsers,
expectQueryParams(t, map[string]string{
- "q": "location:finland language:go",
+ "q": "type:user location:finland language:go",
"sort": "followers",
"order": "desc",
"page": "1",
@@ -391,7 +386,7 @@ func Test_SearchUsers(t *testing.T) {
mock.WithRequestMatchHandler(
mock.GetSearchUsers,
expectQueryParams(t, map[string]string{
- "q": "location:finland language:go",
+ "q": "type:user location:finland language:go",
"page": "1",
"per_page": "30",
}).andThen(
@@ -451,19 +446,17 @@ func Test_SearchUsers(t *testing.T) {
textContent := getTextResult(t, result)
// Unmarshal and verify the result
- var returnedResult github.UsersSearchResult
+ var returnedResult MinimalSearchUsersResult
err = json.Unmarshal([]byte(textContent.Text), &returnedResult)
require.NoError(t, err)
- assert.Equal(t, *tc.expectedResult.Total, *returnedResult.Total)
- assert.Equal(t, *tc.expectedResult.IncompleteResults, *returnedResult.IncompleteResults)
- assert.Len(t, returnedResult.Users, len(tc.expectedResult.Users))
- for i, user := range returnedResult.Users {
- assert.Equal(t, *tc.expectedResult.Users[i].Login, *user.Login)
- assert.Equal(t, *tc.expectedResult.Users[i].ID, *user.ID)
- assert.Equal(t, *tc.expectedResult.Users[i].HTMLURL, *user.HTMLURL)
- assert.Equal(t, *tc.expectedResult.Users[i].AvatarURL, *user.AvatarURL)
- assert.Equal(t, *tc.expectedResult.Users[i].Type, *user.Type)
- assert.Equal(t, *tc.expectedResult.Users[i].Followers, *user.Followers)
+ assert.Equal(t, *tc.expectedResult.Total, returnedResult.TotalCount)
+ assert.Equal(t, *tc.expectedResult.IncompleteResults, returnedResult.IncompleteResults)
+ assert.Len(t, returnedResult.Items, len(tc.expectedResult.Users))
+ for i, user := range returnedResult.Items {
+ assert.Equal(t, *tc.expectedResult.Users[i].Login, user.Login)
+ assert.Equal(t, *tc.expectedResult.Users[i].ID, user.ID)
+ assert.Equal(t, *tc.expectedResult.Users[i].HTMLURL, user.ProfileURL)
+ assert.Equal(t, *tc.expectedResult.Users[i].AvatarURL, user.AvatarURL)
}
})
}
diff --git a/pkg/github/secret_scanning.go b/pkg/github/secret_scanning.go
index ee3440616..ec0eb15a7 100644
--- a/pkg/github/secret_scanning.go
+++ b/pkg/github/secret_scanning.go
@@ -8,7 +8,7 @@ import (
"net/http"
"github.com/github/github-mcp-server/pkg/translations"
- "github.com/google/go-github/v69/github"
+ "github.com/google/go-github/v72/github"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
)
@@ -17,6 +17,10 @@ func GetSecretScanningAlert(getClient GetClientFn, t translations.TranslationHel
return mcp.NewTool(
"get_secret_scanning_alert",
mcp.WithDescription(t("TOOL_GET_SECRET_SCANNING_ALERT_DESCRIPTION", "Get details of a specific secret scanning alert in a GitHub repository.")),
+ mcp.WithToolAnnotation(mcp.ToolAnnotation{
+ Title: t("TOOL_GET_SECRET_SCANNING_ALERT_USER_TITLE", "Get secret scanning alert"),
+ ReadOnlyHint: ToBoolPtr(true),
+ }),
mcp.WithString("owner",
mcp.Required(),
mcp.Description("The owner of the repository."),
@@ -31,11 +35,11 @@ func GetSecretScanningAlert(getClient GetClientFn, t translations.TranslationHel
),
),
func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
- owner, err := requiredParam[string](request, "owner")
+ owner, err := RequiredParam[string](request, "owner")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
- repo, err := requiredParam[string](request, "repo")
+ repo, err := RequiredParam[string](request, "repo")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
@@ -76,6 +80,10 @@ func ListSecretScanningAlerts(getClient GetClientFn, t translations.TranslationH
return mcp.NewTool(
"list_secret_scanning_alerts",
mcp.WithDescription(t("TOOL_LIST_SECRET_SCANNING_ALERTS_DESCRIPTION", "List secret scanning alerts in a GitHub repository.")),
+ mcp.WithToolAnnotation(mcp.ToolAnnotation{
+ Title: t("TOOL_LIST_SECRET_SCANNING_ALERTS_USER_TITLE", "List secret scanning alerts"),
+ ReadOnlyHint: ToBoolPtr(true),
+ }),
mcp.WithString("owner",
mcp.Required(),
mcp.Description("The owner of the repository."),
@@ -97,11 +105,11 @@ func ListSecretScanningAlerts(getClient GetClientFn, t translations.TranslationH
),
),
func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
- owner, err := requiredParam[string](request, "owner")
+ owner, err := RequiredParam[string](request, "owner")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
- repo, err := requiredParam[string](request, "repo")
+ repo, err := RequiredParam[string](request, "repo")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
diff --git a/pkg/github/secret_scanning_test.go b/pkg/github/secret_scanning_test.go
index d32cbca94..4ec5539e8 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/v69/github"
+ "github.com/google/go-github/v72/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 e4c241716..85d078f1b 100644
--- a/pkg/github/server.go
+++ b/pkg/github/server.go
@@ -1,10 +1,11 @@
package github
import (
+ "encoding/json"
"errors"
"fmt"
- "github.com/google/go-github/v69/github"
+ "github.com/google/go-github/v72/github"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
)
@@ -33,7 +34,7 @@ func NewServer(version string, opts ...server.ServerOption) *server.MCPServer {
// It returns the value, a boolean indicating if the parameter was present, and an error if the type is wrong.
func OptionalParamOK[T any](r mcp.CallToolRequest, p string) (value T, ok bool, err error) {
// Check if the parameter is present in the request
- val, exists := r.Params.Arguments[p]
+ val, exists := r.GetArguments()[p]
if !exists {
// Not present, return zero value, false, no error
return
@@ -59,30 +60,30 @@ func isAcceptedError(err error) bool {
return errors.As(err, &acceptedError)
}
-// requiredParam is a helper function that can be used to fetch a requested parameter from the request.
+// RequiredParam is a helper function that can be used to fetch a requested parameter from the request.
// It does the following checks:
// 1. Checks if the parameter is present in the request.
// 2. Checks if the parameter is of the expected type.
// 3. Checks if the parameter is not empty, i.e: non-zero value
-func requiredParam[T comparable](r mcp.CallToolRequest, p string) (T, error) {
+func RequiredParam[T comparable](r mcp.CallToolRequest, p string) (T, error) {
var zero T
// Check if the parameter is present in the request
- if _, ok := r.Params.Arguments[p]; !ok {
+ if _, ok := r.GetArguments()[p]; !ok {
return zero, fmt.Errorf("missing required parameter: %s", p)
}
// Check if the parameter is of the expected type
- if _, ok := r.Params.Arguments[p].(T); !ok {
+ val, ok := r.GetArguments()[p].(T)
+ if !ok {
return zero, fmt.Errorf("parameter %s is not of type %T", p, zero)
}
- if r.Params.Arguments[p].(T) == zero {
+ if val == zero {
return zero, fmt.Errorf("missing required parameter: %s", p)
-
}
- return r.Params.Arguments[p].(T), nil
+ return val, nil
}
// RequiredInt is a helper function that can be used to fetch a requested parameter from the request.
@@ -91,7 +92,7 @@ func requiredParam[T comparable](r mcp.CallToolRequest, p string) (T, error) {
// 2. Checks if the parameter is of the expected type.
// 3. Checks if the parameter is not empty, i.e: non-zero value
func RequiredInt(r mcp.CallToolRequest, p string) (int, error) {
- v, err := requiredParam[float64](r, p)
+ v, err := RequiredParam[float64](r, p)
if err != nil {
return 0, err
}
@@ -106,16 +107,16 @@ func OptionalParam[T any](r mcp.CallToolRequest, p string) (T, error) {
var zero T
// Check if the parameter is present in the request
- if _, ok := r.Params.Arguments[p]; !ok {
+ if _, ok := r.GetArguments()[p]; !ok {
return zero, nil
}
// Check if the parameter is of the expected type
- if _, ok := r.Params.Arguments[p].(T); !ok {
- return zero, fmt.Errorf("parameter %s is not of type %T, is %T", p, zero, r.Params.Arguments[p])
+ if _, ok := r.GetArguments()[p].(T); !ok {
+ return zero, fmt.Errorf("parameter %s is not of type %T, is %T", p, zero, r.GetArguments()[p])
}
- return r.Params.Arguments[p].(T), nil
+ return r.GetArguments()[p].(T), nil
}
// OptionalIntParam is a helper function that can be used to fetch a requested parameter from the request.
@@ -149,11 +150,11 @@ func OptionalIntParamWithDefault(r mcp.CallToolRequest, p string, d int) (int, e
// 2. If it is present, iterates the elements and checks each is a string
func OptionalStringArrayParam(r mcp.CallToolRequest, p string) ([]string, error) {
// Check if the parameter is present in the request
- if _, ok := r.Params.Arguments[p]; !ok {
+ if _, ok := r.GetArguments()[p]; !ok {
return []string{}, nil
}
- switch v := r.Params.Arguments[p].(type) {
+ switch v := r.GetArguments()[p].(type) {
case nil:
return []string{}, nil
case []string:
@@ -169,7 +170,7 @@ func OptionalStringArrayParam(r mcp.CallToolRequest, p string) ([]string, error)
}
return strSlice, nil
default:
- return []string{}, fmt.Errorf("parameter %s could not be coerced to []string, is %T", p, r.Params.Arguments[p])
+ return []string{}, fmt.Errorf("parameter %s could not be coerced to []string, is %T", p, r.GetArguments()[p])
}
}
@@ -214,3 +215,12 @@ func OptionalPaginationParams(r mcp.CallToolRequest) (PaginationParams, error) {
perPage: perPage,
}, nil
}
+
+func MarshalledTextResult(v any) *mcp.CallToolResult {
+ data, err := json.Marshal(v)
+ if err != nil {
+ return mcp.NewToolResultErrorFromErr("failed to marshal text result to json", err)
+ }
+
+ return mcp.NewToolResultText(string(data))
+}
diff --git a/pkg/github/server_test.go b/pkg/github/server_test.go
index 58bcb9dbe..3f00d7b24 100644
--- a/pkg/github/server_test.go
+++ b/pkg/github/server_test.go
@@ -2,10 +2,15 @@ package github
import (
"context"
+ "encoding/json"
+ "errors"
"fmt"
+ "net/http"
"testing"
- "github.com/google/go-github/v69/github"
+ "github.com/github/github-mcp-server/pkg/raw"
+ "github.com/google/go-github/v72/github"
+ "github.com/shurcooL/githubv4"
"github.com/stretchr/testify/assert"
)
@@ -15,6 +20,45 @@ func stubGetClientFn(client *github.Client) GetClientFn {
}
}
+func stubGetClientFromHTTPFn(client *http.Client) GetClientFn {
+ return func(_ context.Context) (*github.Client, error) {
+ return github.NewClient(client), nil
+ }
+}
+
+func stubGetClientFnErr(err string) GetClientFn {
+ return func(_ context.Context) (*github.Client, error) {
+ return nil, errors.New(err)
+ }
+}
+
+func stubGetGQLClientFn(client *githubv4.Client) GetGQLClientFn {
+ return func(_ context.Context) (*githubv4.Client, error) {
+ return client, nil
+ }
+}
+
+func stubGetRawClientFn(client *raw.Client) raw.GetRawClientFn {
+ return func(_ context.Context) (*raw.Client, error) {
+ return client, nil
+ }
+}
+
+func badRequestHandler(msg string) http.HandlerFunc {
+ return func(w http.ResponseWriter, _ *http.Request) {
+ structuredErrorResponse := github.ErrorResponse{
+ Message: msg,
+ }
+
+ b, err := json.Marshal(structuredErrorResponse)
+ if err != nil {
+ http.Error(w, "failed to marshal error response", http.StatusInternalServerError)
+ }
+
+ http.Error(w, string(b), http.StatusBadRequest)
+ }
+}
+
func Test_IsAcceptedError(t *testing.T) {
tests := []struct {
name string
@@ -92,7 +136,7 @@ func Test_RequiredStringParam(t *testing.T) {
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
request := createMCPRequest(tc.params)
- result, err := requiredParam[string](request, tc.paramName)
+ result, err := RequiredParam[string](request, tc.paramName)
if tc.expectError {
assert.Error(t, err)
@@ -157,7 +201,7 @@ func Test_OptionalStringParam(t *testing.T) {
}
}
-func Test_RequiredNumberParam(t *testing.T) {
+func Test_RequiredInt(t *testing.T) {
tests := []struct {
name string
params map[string]interface{}
@@ -202,8 +246,7 @@ func Test_RequiredNumberParam(t *testing.T) {
})
}
}
-
-func Test_OptionalNumberParam(t *testing.T) {
+func Test_OptionalIntParam(t *testing.T) {
tests := []struct {
name string
params map[string]interface{}
diff --git a/pkg/github/tools.go b/pkg/github/tools.go
index 35dabaefd..9569c4390 100644
--- a/pkg/github/tools.go
+++ b/pkg/github/tools.go
@@ -3,18 +3,20 @@ package github
import (
"context"
+ "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/v69/github"
+ "github.com/google/go-github/v72/github"
"github.com/mark3labs/mcp-go/server"
+ "github.com/shurcooL/githubv4"
)
type GetClientFn func(context.Context) (*github.Client, error)
+type GetGQLClientFn func(context.Context) (*githubv4.Client, error)
var DefaultTools = []string{"all"}
-func InitToolsets(passedToolsets []string, readOnly bool, getClient GetClientFn, t translations.TranslationHelperFunc) (*toolsets.ToolsetGroup, error) {
- // Create a new toolset group
+func DefaultToolsetGroup(readOnly bool, getClient GetClientFn, getGQLClient GetGQLClientFn, getRawClient raw.GetRawClientFn, t translations.TranslationHelperFunc) *toolsets.ToolsetGroup {
tsg := toolsets.NewToolsetGroup(readOnly)
// Define all available features with their default state (disabled)
@@ -22,11 +24,13 @@ func InitToolsets(passedToolsets []string, readOnly bool, getClient GetClientFn,
repos := toolsets.NewToolset("repos", "GitHub Repository related tools").
AddReadTools(
toolsets.NewServerTool(SearchRepositories(getClient, t)),
- toolsets.NewServerTool(GetFileContents(getClient, t)),
+ toolsets.NewServerTool(GetFileContents(getClient, getRawClient, t)),
toolsets.NewServerTool(ListCommits(getClient, t)),
toolsets.NewServerTool(SearchCode(getClient, t)),
toolsets.NewServerTool(GetCommit(getClient, t)),
toolsets.NewServerTool(ListBranches(getClient, t)),
+ toolsets.NewServerTool(ListTags(getClient, t)),
+ toolsets.NewServerTool(GetTag(getClient, t)),
).
AddWriteTools(
toolsets.NewServerTool(CreateOrUpdateFile(getClient, t)),
@@ -34,6 +38,14 @@ func InitToolsets(passedToolsets []string, readOnly bool, getClient GetClientFn,
toolsets.NewServerTool(ForkRepository(getClient, t)),
toolsets.NewServerTool(CreateBranch(getClient, t)),
toolsets.NewServerTool(PushFiles(getClient, t)),
+ toolsets.NewServerTool(DeleteFile(getClient, t)),
+ ).
+ AddResourceTemplates(
+ toolsets.NewServerResourceTemplate(GetRepositoryResourceContent(getClient, getRawClient, t)),
+ toolsets.NewServerResourceTemplate(GetRepositoryResourceBranchContent(getClient, getRawClient, t)),
+ toolsets.NewServerResourceTemplate(GetRepositoryResourceCommitContent(getClient, getRawClient, t)),
+ toolsets.NewServerResourceTemplate(GetRepositoryResourceTagContent(getClient, getRawClient, t)),
+ toolsets.NewServerResourceTemplate(GetRepositoryResourcePrContent(getClient, getRawClient, t)),
)
issues := toolsets.NewToolset("issues", "GitHub Issues related tools").
AddReadTools(
@@ -46,6 +58,7 @@ func InitToolsets(passedToolsets []string, readOnly bool, getClient GetClientFn,
toolsets.NewServerTool(CreateIssue(getClient, t)),
toolsets.NewServerTool(AddIssueComment(getClient, t)),
toolsets.NewServerTool(UpdateIssue(getClient, t)),
+ toolsets.NewServerTool(AssignCopilotToIssue(getGQLClient, t)),
)
users := toolsets.NewToolset("users", "GitHub User related tools").
AddReadTools(
@@ -59,14 +72,21 @@ func InitToolsets(passedToolsets []string, readOnly bool, getClient GetClientFn,
toolsets.NewServerTool(GetPullRequestStatus(getClient, t)),
toolsets.NewServerTool(GetPullRequestComments(getClient, t)),
toolsets.NewServerTool(GetPullRequestReviews(getClient, t)),
+ toolsets.NewServerTool(GetPullRequestDiff(getClient, t)),
).
AddWriteTools(
toolsets.NewServerTool(MergePullRequest(getClient, t)),
toolsets.NewServerTool(UpdatePullRequestBranch(getClient, t)),
- toolsets.NewServerTool(CreatePullRequestReview(getClient, t)),
toolsets.NewServerTool(CreatePullRequest(getClient, t)),
toolsets.NewServerTool(UpdatePullRequest(getClient, t)),
- toolsets.NewServerTool(AddPullRequestReviewComment(getClient, t)),
+ toolsets.NewServerTool(RequestCopilotReview(getClient, t)),
+
+ // Reviews
+ toolsets.NewServerTool(CreateAndSubmitPullRequestReview(getGQLClient, t)),
+ toolsets.NewServerTool(CreatePendingPullRequestReview(getGQLClient, t)),
+ toolsets.NewServerTool(AddPullRequestReviewCommentToPendingReview(getGQLClient, t)),
+ toolsets.NewServerTool(SubmitPendingPullRequestReview(getGQLClient, t)),
+ toolsets.NewServerTool(DeletePendingPullRequestReview(getGQLClient, t)),
)
codeSecurity := toolsets.NewToolset("code_security", "Code security related tools, such as GitHub Code Scanning").
AddReadTools(
@@ -78,34 +98,39 @@ func InitToolsets(passedToolsets []string, readOnly bool, getClient GetClientFn,
toolsets.NewServerTool(GetSecretScanningAlert(getClient, t)),
toolsets.NewServerTool(ListSecretScanningAlerts(getClient, t)),
)
+
+ notifications := toolsets.NewToolset("notifications", "GitHub Notifications related tools").
+ AddReadTools(
+ toolsets.NewServerTool(ListNotifications(getClient, t)),
+ toolsets.NewServerTool(GetNotificationDetails(getClient, t)),
+ ).
+ AddWriteTools(
+ toolsets.NewServerTool(DismissNotification(getClient, t)),
+ toolsets.NewServerTool(MarkAllNotificationsRead(getClient, t)),
+ toolsets.NewServerTool(ManageNotificationSubscription(getClient, t)),
+ toolsets.NewServerTool(ManageRepositoryNotificationSubscription(getClient, t)),
+ )
+
// Keep experiments alive so the system doesn't error out when it's always enabled
experiments := toolsets.NewToolset("experiments", "Experimental features that are not considered stable yet")
+ contextTools := toolsets.NewToolset("context", "Tools that provide context about the current user and GitHub context you are operating in").
+ AddReadTools(
+ toolsets.NewServerTool(GetMe(getClient, t)),
+ )
+
// Add toolsets to the group
+ tsg.AddToolset(contextTools)
tsg.AddToolset(repos)
tsg.AddToolset(issues)
tsg.AddToolset(users)
tsg.AddToolset(pullRequests)
tsg.AddToolset(codeSecurity)
tsg.AddToolset(secretProtection)
+ tsg.AddToolset(notifications)
tsg.AddToolset(experiments)
- // Enable the requested features
-
- if err := tsg.EnableToolsets(passedToolsets); err != nil {
- return nil, err
- }
- return tsg, nil
-}
-
-func InitContextToolset(getClient GetClientFn, t translations.TranslationHelperFunc) *toolsets.Toolset {
- // Create a new context toolset
- contextTools := toolsets.NewToolset("context", "Tools that provide context about the current user and GitHub context you are operating in").
- AddReadTools(
- toolsets.NewServerTool(GetMe(getClient, t)),
- )
- contextTools.Enabled = true
- return contextTools
+ return tsg
}
// InitDynamicToolset creates a dynamic toolset that can be used to enable other toolsets, and so requires the server and toolset group as arguments
@@ -118,6 +143,12 @@ func InitDynamicToolset(s *server.MCPServer, tsg *toolsets.ToolsetGroup, t trans
toolsets.NewServerTool(GetToolsetsTools(tsg, t)),
toolsets.NewServerTool(EnableToolset(s, tsg, t)),
)
+
dynamicToolSelection.Enabled = true
return dynamicToolSelection
}
+
+// ToBoolPtr converts a bool to a *bool pointer.
+func ToBoolPtr(b bool) *bool {
+ return &b
+}
diff --git a/pkg/raw/raw.go b/pkg/raw/raw.go
new file mode 100644
index 000000000..d604891b6
--- /dev/null
+++ b/pkg/raw/raw.go
@@ -0,0 +1,69 @@
+// Package raw provides a client for interacting with the GitHub raw file API
+package raw
+
+import (
+ "context"
+ "net/http"
+ "net/url"
+
+ gogithub "github.com/google/go-github/v72/github"
+)
+
+// GetRawClientFn is a function type that returns a RawClient instance.
+type GetRawClientFn func(context.Context) (*Client, error)
+
+// Client is a client for interacting with the GitHub raw content API.
+type Client struct {
+ url *url.URL
+ client *gogithub.Client
+}
+
+// NewClient creates a new instance of the raw API Client with the provided GitHub client and provided URL.
+func NewClient(client *gogithub.Client, rawURL *url.URL) *Client {
+ client = gogithub.NewClient(client.Client())
+ client.BaseURL = rawURL
+ return &Client{client: client, url: rawURL}
+}
+
+func (c *Client) newRequest(method string, urlStr string, body interface{}, opts ...gogithub.RequestOption) (*http.Request, error) {
+ req, err := c.client.NewRequest(method, urlStr, body, opts...)
+ return req, err
+}
+
+func (c *Client) refURL(owner, repo, ref, path string) string {
+ if ref == "" {
+ return c.url.JoinPath(owner, repo, "HEAD", path).String()
+ }
+ return c.url.JoinPath(owner, repo, ref, path).String()
+}
+
+func (c *Client) URLFromOpts(opts *RawContentOpts, owner, repo, path string) string {
+ if opts == nil {
+ opts = &RawContentOpts{}
+ }
+ if opts.SHA != "" {
+ return c.commitURL(owner, repo, opts.SHA, path)
+ }
+ return c.refURL(owner, repo, opts.Ref, path)
+}
+
+// BlobURL returns the URL for a blob in the raw content API.
+func (c *Client) commitURL(owner, repo, sha, path string) string {
+ return c.url.JoinPath(owner, repo, sha, path).String()
+}
+
+type RawContentOpts 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) {
+ url := c.URLFromOpts(opts, owner, repo, path)
+ req, err := c.newRequest("GET", url, nil)
+ if err != nil {
+ return nil, err
+ }
+
+ return c.client.Client().Do(req)
+}
diff --git a/pkg/raw/raw_mock.go b/pkg/raw/raw_mock.go
new file mode 100644
index 000000000..30c7759d3
--- /dev/null
+++ b/pkg/raw/raw_mock.go
@@ -0,0 +1,20 @@
+package raw
+
+import "github.com/migueleliasweb/go-github-mock/src/mock"
+
+var GetRawReposContentsByOwnerByRepoByPath mock.EndpointPattern = mock.EndpointPattern{
+ Pattern: "/{owner}/{repo}/HEAD/{path:.*}",
+ Method: "GET",
+}
+var GetRawReposContentsByOwnerByRepoByBranchByPath mock.EndpointPattern = mock.EndpointPattern{
+ Pattern: "/{owner}/{repo}/refs/heads/{branch}/{path:.*}",
+ Method: "GET",
+}
+var GetRawReposContentsByOwnerByRepoByTagByPath mock.EndpointPattern = mock.EndpointPattern{
+ Pattern: "/{owner}/{repo}/refs/tags/{tag}/{path:.*}",
+ Method: "GET",
+}
+var GetRawReposContentsByOwnerByRepoBySHAByPath mock.EndpointPattern = mock.EndpointPattern{
+ Pattern: "/{owner}/{repo}/{sha}/{path:.*}",
+ Method: "GET",
+}
diff --git a/pkg/raw/raw_test.go b/pkg/raw/raw_test.go
new file mode 100644
index 000000000..bb9b23a28
--- /dev/null
+++ b/pkg/raw/raw_test.go
@@ -0,0 +1,150 @@
+package raw
+
+import (
+ "context"
+ "net/http"
+ "net/url"
+ "testing"
+
+ "github.com/google/go-github/v72/github"
+ "github.com/migueleliasweb/go-github-mock/src/mock"
+ "github.com/stretchr/testify/require"
+)
+
+func TestGetRawContent(t *testing.T) {
+ base, _ := url.Parse("https://raw.example.com/")
+
+ tests := []struct {
+ name string
+ pattern mock.EndpointPattern
+ opts *RawContentOpts
+ owner, repo, path string
+ statusCode int
+ contentType string
+ body string
+ expectError string
+ }{
+ {
+ name: "HEAD fetch success",
+ pattern: GetRawReposContentsByOwnerByRepoByPath,
+ opts: nil,
+ owner: "octocat", repo: "hello", path: "README.md",
+ statusCode: 200,
+ contentType: "text/plain",
+ body: "# Test file",
+ },
+ {
+ name: "branch fetch success",
+ pattern: GetRawReposContentsByOwnerByRepoByBranchByPath,
+ opts: &RawContentOpts{Ref: "refs/heads/main"},
+ owner: "octocat", repo: "hello", path: "README.md",
+ statusCode: 200,
+ contentType: "text/plain",
+ body: "# Test file",
+ },
+ {
+ name: "tag fetch success",
+ pattern: GetRawReposContentsByOwnerByRepoByTagByPath,
+ opts: &RawContentOpts{Ref: "refs/tags/v1.0.0"},
+ owner: "octocat", repo: "hello", path: "README.md",
+ statusCode: 200,
+ contentType: "text/plain",
+ body: "# Test file",
+ },
+ {
+ name: "sha fetch success",
+ pattern: GetRawReposContentsByOwnerByRepoBySHAByPath,
+ opts: &RawContentOpts{SHA: "abc123"},
+ owner: "octocat", repo: "hello", path: "README.md",
+ statusCode: 200,
+ contentType: "text/plain",
+ body: "# Test file",
+ },
+ {
+ name: "not found",
+ pattern: GetRawReposContentsByOwnerByRepoByPath,
+ opts: nil,
+ owner: "octocat", repo: "hello", path: "notfound.txt",
+ statusCode: 404,
+ contentType: "application/json",
+ body: `{"message": "Not Found"}`,
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ mockedClient := mock.NewMockedHTTPClient(
+ mock.WithRequestMatchHandler(
+ tc.pattern,
+ http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.Header().Set("Content-Type", tc.contentType)
+ w.WriteHeader(tc.statusCode)
+ _, err := w.Write([]byte(tc.body))
+ require.NoError(t, err)
+ }),
+ ),
+ )
+ ghClient := github.NewClient(mockedClient)
+ client := NewClient(ghClient, base)
+ resp, err := client.GetRawContent(context.Background(), tc.owner, tc.repo, tc.path, tc.opts)
+ defer func() {
+ _ = resp.Body.Close()
+ }()
+ if tc.expectError != "" {
+ require.Error(t, err)
+ return
+ }
+ require.NoError(t, err)
+ require.Equal(t, tc.statusCode, resp.StatusCode)
+ })
+ }
+}
+
+func TestUrlFromOpts(t *testing.T) {
+ base, _ := url.Parse("https://raw.example.com/")
+ ghClient := github.NewClient(nil)
+ client := NewClient(ghClient, base)
+
+ tests := []struct {
+ name string
+ opts *RawContentOpts
+ owner string
+ repo string
+ path string
+ want string
+ }{
+ {
+ name: "no opts (HEAD)",
+ opts: nil,
+ owner: "octocat", repo: "hello", path: "README.md",
+ want: "https://raw.example.com/octocat/hello/HEAD/README.md",
+ },
+ {
+ name: "ref branch",
+ opts: &RawContentOpts{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"},
+ 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"},
+ owner: "octocat", repo: "hello", path: "README.md",
+ want: "https://raw.example.com/octocat/hello/abc123/README.md",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := client.URLFromOpts(tt.opts, tt.owner, tt.repo, tt.path)
+ if got != tt.want {
+ t.Errorf("UrlFromOpts() = %q, want %q", got, tt.want)
+ }
+ })
+ }
+}
diff --git a/pkg/toolsets/toolsets.go b/pkg/toolsets/toolsets.go
index d4397fc92..ad444c050 100644
--- a/pkg/toolsets/toolsets.go
+++ b/pkg/toolsets/toolsets.go
@@ -7,10 +7,46 @@ import (
"github.com/mark3labs/mcp-go/server"
)
+type ToolsetDoesNotExistError struct {
+ Name string
+}
+
+func (e *ToolsetDoesNotExistError) Error() string {
+ return fmt.Sprintf("toolset %s does not exist", e.Name)
+}
+
+func (e *ToolsetDoesNotExistError) Is(target error) bool {
+ if target == nil {
+ return false
+ }
+ if _, ok := target.(*ToolsetDoesNotExistError); ok {
+ return true
+ }
+ return false
+}
+
+func NewToolsetDoesNotExistError(name string) *ToolsetDoesNotExistError {
+ return &ToolsetDoesNotExistError{Name: name}
+}
+
func NewServerTool(tool mcp.Tool, handler server.ToolHandlerFunc) server.ServerTool {
return server.ServerTool{Tool: tool, Handler: handler}
}
+func NewServerResourceTemplate(resourceTemplate mcp.ResourceTemplate, handler server.ResourceTemplateHandlerFunc) ServerResourceTemplate {
+ return ServerResourceTemplate{
+ resourceTemplate: resourceTemplate,
+ handler: handler,
+ }
+}
+
+// ServerResourceTemplate represents a resource template that can be registered with the MCP server.
+type ServerResourceTemplate struct {
+ resourceTemplate mcp.ResourceTemplate
+ handler server.ResourceTemplateHandlerFunc
+}
+
+// Toolset represents a collection of MCP functionality that can be enabled or disabled as a group.
type Toolset struct {
Name string
Description string
@@ -18,6 +54,9 @@ type Toolset struct {
readOnly bool
writeTools []server.ServerTool
readTools []server.ServerTool
+ // resources are not tools, but the community seems to be moving towards namespaces as a broader concept
+ // and in order to have multiple servers running concurrently, we want to avoid overlapping resources too.
+ resourceTemplates []ServerResourceTemplate
}
func (t *Toolset) GetActiveTools() []server.ServerTool {
@@ -51,6 +90,31 @@ func (t *Toolset) RegisterTools(s *server.MCPServer) {
}
}
+func (t *Toolset) AddResourceTemplates(templates ...ServerResourceTemplate) *Toolset {
+ t.resourceTemplates = append(t.resourceTemplates, templates...)
+ return t
+}
+
+func (t *Toolset) GetActiveResourceTemplates() []ServerResourceTemplate {
+ if !t.Enabled {
+ return nil
+ }
+ return t.resourceTemplates
+}
+
+func (t *Toolset) GetAvailableResourceTemplates() []ServerResourceTemplate {
+ return t.resourceTemplates
+}
+
+func (t *Toolset) RegisterResourcesTemplates(s *server.MCPServer) {
+ if !t.Enabled {
+ return
+ }
+ for _, resource := range t.resourceTemplates {
+ s.AddResourceTemplate(resource.resourceTemplate, resource.handler)
+ }
+}
+
func (t *Toolset) SetReadOnly() {
// Set the toolset to read-only
t.readOnly = true
@@ -58,6 +122,11 @@ func (t *Toolset) SetReadOnly() {
func (t *Toolset) AddWriteTools(tools ...server.ServerTool) *Toolset {
// Silently ignore if the toolset is read-only to avoid any breach of that contract
+ for _, tool := range tools {
+ if *tool.Tool.Annotations.ReadOnlyHint {
+ panic(fmt.Sprintf("tool (%s) is incorrectly annotated as read-only", tool.Tool.Name))
+ }
+ }
if !t.readOnly {
t.writeTools = append(t.writeTools, tools...)
}
@@ -65,6 +134,11 @@ func (t *Toolset) AddWriteTools(tools ...server.ServerTool) *Toolset {
}
func (t *Toolset) AddReadTools(tools ...server.ServerTool) *Toolset {
+ for _, tool := range tools {
+ if !*tool.Tool.Annotations.ReadOnlyHint {
+ panic(fmt.Sprintf("tool (%s) must be annotated as read-only", tool.Tool.Name))
+ }
+ }
t.readTools = append(t.readTools, tools...)
return t
}
@@ -140,15 +214,24 @@ func (tg *ToolsetGroup) EnableToolsets(names []string) error {
func (tg *ToolsetGroup) EnableToolset(name string) error {
toolset, exists := tg.Toolsets[name]
if !exists {
- return fmt.Errorf("toolset %s does not exist", name)
+ return NewToolsetDoesNotExistError(name)
}
toolset.Enabled = true
tg.Toolsets[name] = toolset
return nil
}
-func (tg *ToolsetGroup) RegisterTools(s *server.MCPServer) {
+func (tg *ToolsetGroup) RegisterAll(s *server.MCPServer) {
for _, toolset := range tg.Toolsets {
toolset.RegisterTools(s)
+ toolset.RegisterResourcesTemplates(s)
+ }
+}
+
+func (tg *ToolsetGroup) GetToolset(name string) (*Toolset, error) {
+ toolset, exists := tg.Toolsets[name]
+ if !exists {
+ return nil, NewToolsetDoesNotExistError(name)
}
+ return toolset, nil
}
diff --git a/pkg/toolsets/toolsets_test.go b/pkg/toolsets/toolsets_test.go
index 7ece1df1e..d74c94bbb 100644
--- a/pkg/toolsets/toolsets_test.go
+++ b/pkg/toolsets/toolsets_test.go
@@ -1,17 +1,12 @@
package toolsets
import (
+ "errors"
"testing"
)
-func TestNewToolsetGroup(t *testing.T) {
+func TestNewToolsetGroupIsEmptyWithoutEverythingOn(t *testing.T) {
tsg := NewToolsetGroup(false)
- if tsg == nil {
- t.Fatal("Expected NewToolsetGroup to return a non-nil pointer")
- }
- if tsg.Toolsets == nil {
- t.Fatal("Expected Toolsets map to be initialized")
- }
if len(tsg.Toolsets) != 0 {
t.Fatalf("Expected Toolsets map to be empty, got %d items", len(tsg.Toolsets))
}
@@ -157,6 +152,9 @@ func TestEnableToolsets(t *testing.T) {
if err == nil {
t.Error("Expected error when enabling list with non-existent toolset")
}
+ if !errors.Is(err, NewToolsetDoesNotExistError("non-existent")) {
+ t.Errorf("Expected ToolsetDoesNotExistError when enabling non-existent toolset, got: %v", err)
+ }
// Test with empty list
err = tsg.EnableToolsets([]string{})
@@ -213,7 +211,7 @@ func TestEnableEverything(t *testing.T) {
func TestIsEnabledWithEverythingOn(t *testing.T) {
tsg := NewToolsetGroup(false)
- // Enable "everything"
+ // Enable "all"
err := tsg.EnableToolsets([]string{"all"})
if err != nil {
t.Errorf("Expected no error when enabling 'all', got: %v", err)
@@ -228,3 +226,27 @@ func TestIsEnabledWithEverythingOn(t *testing.T) {
t.Error("Expected IsEnabled to return true for any toolset when everythingOn is true")
}
}
+
+func TestToolsetGroup_GetToolset(t *testing.T) {
+ tsg := NewToolsetGroup(false)
+ toolset := NewToolset("my-toolset", "desc")
+ tsg.AddToolset(toolset)
+
+ // Should find the toolset
+ got, err := tsg.GetToolset("my-toolset")
+ if err != nil {
+ t.Fatalf("expected no error, got %v", err)
+ }
+ if got != toolset {
+ t.Errorf("expected to get the same toolset instance")
+ }
+
+ // Should not find a non-existent toolset
+ _, err = tsg.GetToolset("does-not-exist")
+ if err == nil {
+ t.Error("expected error for missing toolset, got nil")
+ }
+ if !errors.Is(err, NewToolsetDoesNotExistError("does-not-exist")) {
+ t.Errorf("expected error to be ToolsetDoesNotExistError, got %v", err)
+ }
+}
diff --git a/pkg/translations/translations.go b/pkg/translations/translations.go
index 741ee2b50..0cc1c187d 100644
--- a/pkg/translations/translations.go
+++ b/pkg/translations/translations.go
@@ -56,7 +56,7 @@ func TranslationHelper() (TranslationHelperFunc, func()) {
}
}
-// dump translationKeyMap to a json file called github-mcp-server-config.json
+// DumpTranslationKeyMap writes the translation map to a json file called github-mcp-server-config.json
func DumpTranslationKeyMap(translationKeyMap map[string]string) error {
file, err := os.Create("github-mcp-server-config.json")
if err != nil {
diff --git a/third-party-licenses.darwin.md b/third-party-licenses.darwin.md
index 389bb966e..7ba187e1f 100644
--- a/third-party-licenses.darwin.md
+++ b/third-party-licenses.darwin.md
@@ -9,13 +9,23 @@ Some packages may only be included on certain architectures or operating systems
- [github.com/fsnotify/fsnotify](https://pkg.go.dev/github.com/fsnotify/fsnotify) ([BSD-3-Clause](https://github.com/fsnotify/fsnotify/blob/v1.8.0/LICENSE))
- [github.com/github/github-mcp-server](https://pkg.go.dev/github.com/github/github-mcp-server) ([MIT](https://github.com/github/github-mcp-server/blob/HEAD/LICENSE))
+ - [github.com/go-openapi/jsonpointer](https://pkg.go.dev/github.com/go-openapi/jsonpointer) ([Apache-2.0](https://github.com/go-openapi/jsonpointer/blob/v0.19.5/LICENSE))
+ - [github.com/go-openapi/swag](https://pkg.go.dev/github.com/go-openapi/swag) ([Apache-2.0](https://github.com/go-openapi/swag/blob/v0.21.1/LICENSE))
- [github.com/go-viper/mapstructure/v2](https://pkg.go.dev/github.com/go-viper/mapstructure/v2) ([MIT](https://github.com/go-viper/mapstructure/blob/v2.2.1/LICENSE))
- - [github.com/google/go-github/v69/github](https://pkg.go.dev/github.com/google/go-github/v69/github) ([BSD-3-Clause](https://github.com/google/go-github/blob/v69.2.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-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/mark3labs/mcp-go](https://pkg.go.dev/github.com/mark3labs/mcp-go) ([MIT](https://github.com/mark3labs/mcp-go/blob/v0.20.1/LICENSE))
+ - [github.com/gorilla/mux](https://pkg.go.dev/github.com/gorilla/mux) ([BSD-3-Clause](https://github.com/gorilla/mux/blob/v1.8.0/LICENSE))
+ - [github.com/josephburnett/jd/v2](https://pkg.go.dev/github.com/josephburnett/jd/v2) ([MIT](https://github.com/josephburnett/jd/blob/v1.9.2/LICENSE))
+ - [github.com/josharian/intern](https://pkg.go.dev/github.com/josharian/intern) ([MIT](https://github.com/josharian/intern/blob/v1.0.0/license.md))
+ - [github.com/mailru/easyjson](https://pkg.go.dev/github.com/mailru/easyjson) ([MIT](https://github.com/mailru/easyjson/blob/v0.7.7/LICENSE))
+ - [github.com/mark3labs/mcp-go](https://pkg.go.dev/github.com/mark3labs/mcp-go) ([MIT](https://github.com/mark3labs/mcp-go/blob/v0.31.0/LICENSE))
+ - [github.com/migueleliasweb/go-github-mock/src/mock](https://pkg.go.dev/github.com/migueleliasweb/go-github-mock/src/mock) ([MIT](https://github.com/migueleliasweb/go-github-mock/blob/v1.3.0/LICENSE))
- [github.com/pelletier/go-toml/v2](https://pkg.go.dev/github.com/pelletier/go-toml/v2) ([MIT](https://github.com/pelletier/go-toml/blob/v2.2.3/LICENSE))
- [github.com/sagikazarmark/locafero](https://pkg.go.dev/github.com/sagikazarmark/locafero) ([MIT](https://github.com/sagikazarmark/locafero/blob/v0.9.0/LICENSE))
+ - [github.com/shurcooL/githubv4](https://pkg.go.dev/github.com/shurcooL/githubv4) ([MIT](https://github.com/shurcooL/githubv4/blob/48295856cce7/LICENSE))
+ - [github.com/shurcooL/graphql](https://pkg.go.dev/github.com/shurcooL/graphql) ([MIT](https://github.com/shurcooL/graphql/blob/ed46e5a46466/LICENSE))
- [github.com/sirupsen/logrus](https://pkg.go.dev/github.com/sirupsen/logrus) ([MIT](https://github.com/sirupsen/logrus/blob/v1.9.3/LICENSE))
- [github.com/sourcegraph/conc](https://pkg.go.dev/github.com/sourcegraph/conc) ([MIT](https://github.com/sourcegraph/conc/blob/v0.3.0/LICENSE))
- [github.com/spf13/afero](https://pkg.go.dev/github.com/spf13/afero) ([Apache-2.0](https://github.com/spf13/afero/blob/v1.14.0/LICENSE.txt))
@@ -25,8 +35,12 @@ Some packages may only be included on certain architectures or operating systems
- [github.com/spf13/viper](https://pkg.go.dev/github.com/spf13/viper) ([MIT](https://github.com/spf13/viper/blob/v1.20.1/LICENSE))
- [github.com/subosito/gotenv](https://pkg.go.dev/github.com/subosito/gotenv) ([MIT](https://github.com/subosito/gotenv/blob/v1.6.0/LICENSE))
- [github.com/yosida95/uritemplate/v3](https://pkg.go.dev/github.com/yosida95/uritemplate/v3) ([BSD-3-Clause](https://github.com/yosida95/uritemplate/blob/v3.0.2/LICENSE))
+ - [github.com/yudai/golcs](https://pkg.go.dev/github.com/yudai/golcs) ([MIT](https://github.com/yudai/golcs/blob/ecda9a501e82/LICENSE))
+ - [golang.org/x/exp](https://pkg.go.dev/golang.org/x/exp) ([BSD-3-Clause](https://cs.opensource.google/go/x/exp/+/8a7402ab:LICENSE))
- [golang.org/x/sys/unix](https://pkg.go.dev/golang.org/x/sys/unix) ([BSD-3-Clause](https://cs.opensource.google/go/x/sys/+/v0.31.0:LICENSE))
- [golang.org/x/text](https://pkg.go.dev/golang.org/x/text) ([BSD-3-Clause](https://cs.opensource.google/go/x/text/+/v0.23.0:LICENSE))
+ - [golang.org/x/time/rate](https://pkg.go.dev/golang.org/x/time/rate) ([BSD-3-Clause](https://cs.opensource.google/go/x/time/+/v0.5.0:LICENSE))
+ - [gopkg.in/yaml.v2](https://pkg.go.dev/gopkg.in/yaml.v2) ([Apache-2.0](https://github.com/go-yaml/yaml/blob/v2.4.0/LICENSE))
- [gopkg.in/yaml.v3](https://pkg.go.dev/gopkg.in/yaml.v3) ([MIT](https://github.com/go-yaml/yaml/blob/v3.0.1/LICENSE))
[github/github-mcp-server]: https://github.com/github/github-mcp-server
diff --git a/third-party-licenses.linux.md b/third-party-licenses.linux.md
index 389bb966e..7ba187e1f 100644
--- a/third-party-licenses.linux.md
+++ b/third-party-licenses.linux.md
@@ -9,13 +9,23 @@ Some packages may only be included on certain architectures or operating systems
- [github.com/fsnotify/fsnotify](https://pkg.go.dev/github.com/fsnotify/fsnotify) ([BSD-3-Clause](https://github.com/fsnotify/fsnotify/blob/v1.8.0/LICENSE))
- [github.com/github/github-mcp-server](https://pkg.go.dev/github.com/github/github-mcp-server) ([MIT](https://github.com/github/github-mcp-server/blob/HEAD/LICENSE))
+ - [github.com/go-openapi/jsonpointer](https://pkg.go.dev/github.com/go-openapi/jsonpointer) ([Apache-2.0](https://github.com/go-openapi/jsonpointer/blob/v0.19.5/LICENSE))
+ - [github.com/go-openapi/swag](https://pkg.go.dev/github.com/go-openapi/swag) ([Apache-2.0](https://github.com/go-openapi/swag/blob/v0.21.1/LICENSE))
- [github.com/go-viper/mapstructure/v2](https://pkg.go.dev/github.com/go-viper/mapstructure/v2) ([MIT](https://github.com/go-viper/mapstructure/blob/v2.2.1/LICENSE))
- - [github.com/google/go-github/v69/github](https://pkg.go.dev/github.com/google/go-github/v69/github) ([BSD-3-Clause](https://github.com/google/go-github/blob/v69.2.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-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/mark3labs/mcp-go](https://pkg.go.dev/github.com/mark3labs/mcp-go) ([MIT](https://github.com/mark3labs/mcp-go/blob/v0.20.1/LICENSE))
+ - [github.com/gorilla/mux](https://pkg.go.dev/github.com/gorilla/mux) ([BSD-3-Clause](https://github.com/gorilla/mux/blob/v1.8.0/LICENSE))
+ - [github.com/josephburnett/jd/v2](https://pkg.go.dev/github.com/josephburnett/jd/v2) ([MIT](https://github.com/josephburnett/jd/blob/v1.9.2/LICENSE))
+ - [github.com/josharian/intern](https://pkg.go.dev/github.com/josharian/intern) ([MIT](https://github.com/josharian/intern/blob/v1.0.0/license.md))
+ - [github.com/mailru/easyjson](https://pkg.go.dev/github.com/mailru/easyjson) ([MIT](https://github.com/mailru/easyjson/blob/v0.7.7/LICENSE))
+ - [github.com/mark3labs/mcp-go](https://pkg.go.dev/github.com/mark3labs/mcp-go) ([MIT](https://github.com/mark3labs/mcp-go/blob/v0.31.0/LICENSE))
+ - [github.com/migueleliasweb/go-github-mock/src/mock](https://pkg.go.dev/github.com/migueleliasweb/go-github-mock/src/mock) ([MIT](https://github.com/migueleliasweb/go-github-mock/blob/v1.3.0/LICENSE))
- [github.com/pelletier/go-toml/v2](https://pkg.go.dev/github.com/pelletier/go-toml/v2) ([MIT](https://github.com/pelletier/go-toml/blob/v2.2.3/LICENSE))
- [github.com/sagikazarmark/locafero](https://pkg.go.dev/github.com/sagikazarmark/locafero) ([MIT](https://github.com/sagikazarmark/locafero/blob/v0.9.0/LICENSE))
+ - [github.com/shurcooL/githubv4](https://pkg.go.dev/github.com/shurcooL/githubv4) ([MIT](https://github.com/shurcooL/githubv4/blob/48295856cce7/LICENSE))
+ - [github.com/shurcooL/graphql](https://pkg.go.dev/github.com/shurcooL/graphql) ([MIT](https://github.com/shurcooL/graphql/blob/ed46e5a46466/LICENSE))
- [github.com/sirupsen/logrus](https://pkg.go.dev/github.com/sirupsen/logrus) ([MIT](https://github.com/sirupsen/logrus/blob/v1.9.3/LICENSE))
- [github.com/sourcegraph/conc](https://pkg.go.dev/github.com/sourcegraph/conc) ([MIT](https://github.com/sourcegraph/conc/blob/v0.3.0/LICENSE))
- [github.com/spf13/afero](https://pkg.go.dev/github.com/spf13/afero) ([Apache-2.0](https://github.com/spf13/afero/blob/v1.14.0/LICENSE.txt))
@@ -25,8 +35,12 @@ Some packages may only be included on certain architectures or operating systems
- [github.com/spf13/viper](https://pkg.go.dev/github.com/spf13/viper) ([MIT](https://github.com/spf13/viper/blob/v1.20.1/LICENSE))
- [github.com/subosito/gotenv](https://pkg.go.dev/github.com/subosito/gotenv) ([MIT](https://github.com/subosito/gotenv/blob/v1.6.0/LICENSE))
- [github.com/yosida95/uritemplate/v3](https://pkg.go.dev/github.com/yosida95/uritemplate/v3) ([BSD-3-Clause](https://github.com/yosida95/uritemplate/blob/v3.0.2/LICENSE))
+ - [github.com/yudai/golcs](https://pkg.go.dev/github.com/yudai/golcs) ([MIT](https://github.com/yudai/golcs/blob/ecda9a501e82/LICENSE))
+ - [golang.org/x/exp](https://pkg.go.dev/golang.org/x/exp) ([BSD-3-Clause](https://cs.opensource.google/go/x/exp/+/8a7402ab:LICENSE))
- [golang.org/x/sys/unix](https://pkg.go.dev/golang.org/x/sys/unix) ([BSD-3-Clause](https://cs.opensource.google/go/x/sys/+/v0.31.0:LICENSE))
- [golang.org/x/text](https://pkg.go.dev/golang.org/x/text) ([BSD-3-Clause](https://cs.opensource.google/go/x/text/+/v0.23.0:LICENSE))
+ - [golang.org/x/time/rate](https://pkg.go.dev/golang.org/x/time/rate) ([BSD-3-Clause](https://cs.opensource.google/go/x/time/+/v0.5.0:LICENSE))
+ - [gopkg.in/yaml.v2](https://pkg.go.dev/gopkg.in/yaml.v2) ([Apache-2.0](https://github.com/go-yaml/yaml/blob/v2.4.0/LICENSE))
- [gopkg.in/yaml.v3](https://pkg.go.dev/gopkg.in/yaml.v3) ([MIT](https://github.com/go-yaml/yaml/blob/v3.0.1/LICENSE))
[github/github-mcp-server]: https://github.com/github/github-mcp-server
diff --git a/third-party-licenses.windows.md b/third-party-licenses.windows.md
index 96d037cc0..1c8b6c588 100644
--- a/third-party-licenses.windows.md
+++ b/third-party-licenses.windows.md
@@ -9,14 +9,24 @@ Some packages may only be included on certain architectures or operating systems
- [github.com/fsnotify/fsnotify](https://pkg.go.dev/github.com/fsnotify/fsnotify) ([BSD-3-Clause](https://github.com/fsnotify/fsnotify/blob/v1.8.0/LICENSE))
- [github.com/github/github-mcp-server](https://pkg.go.dev/github.com/github/github-mcp-server) ([MIT](https://github.com/github/github-mcp-server/blob/HEAD/LICENSE))
+ - [github.com/go-openapi/jsonpointer](https://pkg.go.dev/github.com/go-openapi/jsonpointer) ([Apache-2.0](https://github.com/go-openapi/jsonpointer/blob/v0.19.5/LICENSE))
+ - [github.com/go-openapi/swag](https://pkg.go.dev/github.com/go-openapi/swag) ([Apache-2.0](https://github.com/go-openapi/swag/blob/v0.21.1/LICENSE))
- [github.com/go-viper/mapstructure/v2](https://pkg.go.dev/github.com/go-viper/mapstructure/v2) ([MIT](https://github.com/go-viper/mapstructure/blob/v2.2.1/LICENSE))
- - [github.com/google/go-github/v69/github](https://pkg.go.dev/github.com/google/go-github/v69/github) ([BSD-3-Clause](https://github.com/google/go-github/blob/v69.2.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-querystring/query](https://pkg.go.dev/github.com/google/go-querystring/query) ([BSD-3-Clause](https://github.com/google/go-querystring/blob/v1.1.0/LICENSE))
- [github.com/google/uuid](https://pkg.go.dev/github.com/google/uuid) ([BSD-3-Clause](https://github.com/google/uuid/blob/v1.6.0/LICENSE))
+ - [github.com/gorilla/mux](https://pkg.go.dev/github.com/gorilla/mux) ([BSD-3-Clause](https://github.com/gorilla/mux/blob/v1.8.0/LICENSE))
- [github.com/inconshreveable/mousetrap](https://pkg.go.dev/github.com/inconshreveable/mousetrap) ([Apache-2.0](https://github.com/inconshreveable/mousetrap/blob/v1.1.0/LICENSE))
- - [github.com/mark3labs/mcp-go](https://pkg.go.dev/github.com/mark3labs/mcp-go) ([MIT](https://github.com/mark3labs/mcp-go/blob/v0.20.1/LICENSE))
+ - [github.com/josephburnett/jd/v2](https://pkg.go.dev/github.com/josephburnett/jd/v2) ([MIT](https://github.com/josephburnett/jd/blob/v1.9.2/LICENSE))
+ - [github.com/josharian/intern](https://pkg.go.dev/github.com/josharian/intern) ([MIT](https://github.com/josharian/intern/blob/v1.0.0/license.md))
+ - [github.com/mailru/easyjson](https://pkg.go.dev/github.com/mailru/easyjson) ([MIT](https://github.com/mailru/easyjson/blob/v0.7.7/LICENSE))
+ - [github.com/mark3labs/mcp-go](https://pkg.go.dev/github.com/mark3labs/mcp-go) ([MIT](https://github.com/mark3labs/mcp-go/blob/v0.31.0/LICENSE))
+ - [github.com/migueleliasweb/go-github-mock/src/mock](https://pkg.go.dev/github.com/migueleliasweb/go-github-mock/src/mock) ([MIT](https://github.com/migueleliasweb/go-github-mock/blob/v1.3.0/LICENSE))
- [github.com/pelletier/go-toml/v2](https://pkg.go.dev/github.com/pelletier/go-toml/v2) ([MIT](https://github.com/pelletier/go-toml/blob/v2.2.3/LICENSE))
- [github.com/sagikazarmark/locafero](https://pkg.go.dev/github.com/sagikazarmark/locafero) ([MIT](https://github.com/sagikazarmark/locafero/blob/v0.9.0/LICENSE))
+ - [github.com/shurcooL/githubv4](https://pkg.go.dev/github.com/shurcooL/githubv4) ([MIT](https://github.com/shurcooL/githubv4/blob/48295856cce7/LICENSE))
+ - [github.com/shurcooL/graphql](https://pkg.go.dev/github.com/shurcooL/graphql) ([MIT](https://github.com/shurcooL/graphql/blob/ed46e5a46466/LICENSE))
- [github.com/sirupsen/logrus](https://pkg.go.dev/github.com/sirupsen/logrus) ([MIT](https://github.com/sirupsen/logrus/blob/v1.9.3/LICENSE))
- [github.com/sourcegraph/conc](https://pkg.go.dev/github.com/sourcegraph/conc) ([MIT](https://github.com/sourcegraph/conc/blob/v0.3.0/LICENSE))
- [github.com/spf13/afero](https://pkg.go.dev/github.com/spf13/afero) ([Apache-2.0](https://github.com/spf13/afero/blob/v1.14.0/LICENSE.txt))
@@ -26,8 +36,12 @@ Some packages may only be included on certain architectures or operating systems
- [github.com/spf13/viper](https://pkg.go.dev/github.com/spf13/viper) ([MIT](https://github.com/spf13/viper/blob/v1.20.1/LICENSE))
- [github.com/subosito/gotenv](https://pkg.go.dev/github.com/subosito/gotenv) ([MIT](https://github.com/subosito/gotenv/blob/v1.6.0/LICENSE))
- [github.com/yosida95/uritemplate/v3](https://pkg.go.dev/github.com/yosida95/uritemplate/v3) ([BSD-3-Clause](https://github.com/yosida95/uritemplate/blob/v3.0.2/LICENSE))
+ - [github.com/yudai/golcs](https://pkg.go.dev/github.com/yudai/golcs) ([MIT](https://github.com/yudai/golcs/blob/ecda9a501e82/LICENSE))
+ - [golang.org/x/exp](https://pkg.go.dev/golang.org/x/exp) ([BSD-3-Clause](https://cs.opensource.google/go/x/exp/+/8a7402ab:LICENSE))
- [golang.org/x/sys/windows](https://pkg.go.dev/golang.org/x/sys/windows) ([BSD-3-Clause](https://cs.opensource.google/go/x/sys/+/v0.31.0:LICENSE))
- [golang.org/x/text](https://pkg.go.dev/golang.org/x/text) ([BSD-3-Clause](https://cs.opensource.google/go/x/text/+/v0.23.0:LICENSE))
+ - [golang.org/x/time/rate](https://pkg.go.dev/golang.org/x/time/rate) ([BSD-3-Clause](https://cs.opensource.google/go/x/time/+/v0.5.0:LICENSE))
+ - [gopkg.in/yaml.v2](https://pkg.go.dev/gopkg.in/yaml.v2) ([Apache-2.0](https://github.com/go-yaml/yaml/blob/v2.4.0/LICENSE))
- [gopkg.in/yaml.v3](https://pkg.go.dev/gopkg.in/yaml.v3) ([MIT](https://github.com/go-yaml/yaml/blob/v3.0.1/LICENSE))
[github/github-mcp-server]: https://github.com/github/github-mcp-server
diff --git a/third-party/github.com/go-openapi/jsonpointer/LICENSE b/third-party/github.com/go-openapi/jsonpointer/LICENSE
new file mode 100644
index 000000000..d64569567
--- /dev/null
+++ b/third-party/github.com/go-openapi/jsonpointer/LICENSE
@@ -0,0 +1,202 @@
+
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
+
+ APPENDIX: How to apply the Apache License to your work.
+
+ To apply the Apache License to your work, attach the following
+ boilerplate notice, with the fields enclosed by brackets "[]"
+ replaced with your own identifying information. (Don't include
+ the brackets!) The text should be enclosed in the appropriate
+ comment syntax for the file format. We also recommend that a
+ file or class name and description of purpose be included on the
+ same "printed page" as the copyright notice for easier
+ identification within third-party archives.
+
+ Copyright [yyyy] [name of copyright owner]
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
diff --git a/third-party/github.com/go-openapi/swag/LICENSE b/third-party/github.com/go-openapi/swag/LICENSE
new file mode 100644
index 000000000..d64569567
--- /dev/null
+++ b/third-party/github.com/go-openapi/swag/LICENSE
@@ -0,0 +1,202 @@
+
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
+
+ APPENDIX: How to apply the Apache License to your work.
+
+ To apply the Apache License to your work, attach the following
+ boilerplate notice, with the fields enclosed by brackets "[]"
+ replaced with your own identifying information. (Don't include
+ the brackets!) The text should be enclosed in the appropriate
+ comment syntax for the file format. We also recommend that a
+ file or class name and description of purpose be included on the
+ same "printed page" as the copyright notice for easier
+ identification within third-party archives.
+
+ Copyright [yyyy] [name of copyright owner]
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
diff --git a/third-party/github.com/google/go-github/v69/github/LICENSE b/third-party/github.com/google/go-github/v71/github/LICENSE
similarity index 100%
rename from third-party/github.com/google/go-github/v69/github/LICENSE
rename to third-party/github.com/google/go-github/v71/github/LICENSE
diff --git a/third-party/github.com/google/go-github/v72/github/LICENSE b/third-party/github.com/google/go-github/v72/github/LICENSE
new file mode 100644
index 000000000..28b6486f0
--- /dev/null
+++ b/third-party/github.com/google/go-github/v72/github/LICENSE
@@ -0,0 +1,27 @@
+Copyright (c) 2013 The go-github AUTHORS. All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are
+met:
+
+ * Redistributions of source code must retain the above copyright
+notice, this list of conditions and the following disclaimer.
+ * Redistributions in binary form must reproduce the above
+copyright notice, this list of conditions and the following disclaimer
+in the documentation and/or other materials provided with the
+distribution.
+ * Neither the name of Google Inc. nor the names of its
+contributors may be used to endorse or promote products derived from
+this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
diff --git a/third-party/github.com/gorilla/mux/LICENSE b/third-party/github.com/gorilla/mux/LICENSE
new file mode 100644
index 000000000..6903df638
--- /dev/null
+++ b/third-party/github.com/gorilla/mux/LICENSE
@@ -0,0 +1,27 @@
+Copyright (c) 2012-2018 The Gorilla Authors. All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are
+met:
+
+ * Redistributions of source code must retain the above copyright
+notice, this list of conditions and the following disclaimer.
+ * Redistributions in binary form must reproduce the above
+copyright notice, this list of conditions and the following disclaimer
+in the documentation and/or other materials provided with the
+distribution.
+ * Neither the name of Google Inc. nor the names of its
+contributors may be used to endorse or promote products derived from
+this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
diff --git a/third-party/github.com/josephburnett/jd/v2/LICENSE b/third-party/github.com/josephburnett/jd/v2/LICENSE
new file mode 100644
index 000000000..8e11d69d5
--- /dev/null
+++ b/third-party/github.com/josephburnett/jd/v2/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2016 Joseph Burnett
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/third-party/github.com/josharian/intern/license.md b/third-party/github.com/josharian/intern/license.md
new file mode 100644
index 000000000..353d3055f
--- /dev/null
+++ b/third-party/github.com/josharian/intern/license.md
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2019 Josh Bleecher Snyder
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/third-party/github.com/mailru/easyjson/LICENSE b/third-party/github.com/mailru/easyjson/LICENSE
new file mode 100644
index 000000000..fbff658f7
--- /dev/null
+++ b/third-party/github.com/mailru/easyjson/LICENSE
@@ -0,0 +1,7 @@
+Copyright (c) 2016 Mail.Ru Group
+
+Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
diff --git a/third-party/github.com/migueleliasweb/go-github-mock/src/mock/LICENSE b/third-party/github.com/migueleliasweb/go-github-mock/src/mock/LICENSE
new file mode 100644
index 000000000..86d42717d
--- /dev/null
+++ b/third-party/github.com/migueleliasweb/go-github-mock/src/mock/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2021 Miguel Elias dos Santos
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/third-party/github.com/shurcooL/githubv4/LICENSE b/third-party/github.com/shurcooL/githubv4/LICENSE
new file mode 100644
index 000000000..ca4c77642
--- /dev/null
+++ b/third-party/github.com/shurcooL/githubv4/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2017 Dmitri Shuralyov
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/third-party/github.com/shurcooL/graphql/LICENSE b/third-party/github.com/shurcooL/graphql/LICENSE
new file mode 100644
index 000000000..ca4c77642
--- /dev/null
+++ b/third-party/github.com/shurcooL/graphql/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2017 Dmitri Shuralyov
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/third-party/github.com/yudai/golcs/LICENSE b/third-party/github.com/yudai/golcs/LICENSE
new file mode 100644
index 000000000..ab7d2e0fb
--- /dev/null
+++ b/third-party/github.com/yudai/golcs/LICENSE
@@ -0,0 +1,21 @@
+The MIT License (MIT)
+
+Copyright (c) 2015 Iwasaki Yudai
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
diff --git a/third-party/golang.org/x/exp/LICENSE b/third-party/golang.org/x/exp/LICENSE
new file mode 100644
index 000000000..2a7cf70da
--- /dev/null
+++ b/third-party/golang.org/x/exp/LICENSE
@@ -0,0 +1,27 @@
+Copyright 2009 The Go Authors.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are
+met:
+
+ * Redistributions of source code must retain the above copyright
+notice, this list of conditions and the following disclaimer.
+ * Redistributions in binary form must reproduce the above
+copyright notice, this list of conditions and the following disclaimer
+in the documentation and/or other materials provided with the
+distribution.
+ * Neither the name of Google LLC nor the names of its
+contributors may be used to endorse or promote products derived from
+this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
diff --git a/third-party/golang.org/x/time/rate/LICENSE b/third-party/golang.org/x/time/rate/LICENSE
new file mode 100644
index 000000000..6a66aea5e
--- /dev/null
+++ b/third-party/golang.org/x/time/rate/LICENSE
@@ -0,0 +1,27 @@
+Copyright (c) 2009 The Go Authors. All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are
+met:
+
+ * Redistributions of source code must retain the above copyright
+notice, this list of conditions and the following disclaimer.
+ * Redistributions in binary form must reproduce the above
+copyright notice, this list of conditions and the following disclaimer
+in the documentation and/or other materials provided with the
+distribution.
+ * Neither the name of Google Inc. nor the names of its
+contributors may be used to endorse or promote products derived from
+this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
diff --git a/third-party/gopkg.in/yaml.v2/LICENSE b/third-party/gopkg.in/yaml.v2/LICENSE
new file mode 100644
index 000000000..8dada3eda
--- /dev/null
+++ b/third-party/gopkg.in/yaml.v2/LICENSE
@@ -0,0 +1,201 @@
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
+
+ APPENDIX: How to apply the Apache License to your work.
+
+ To apply the Apache License to your work, attach the following
+ boilerplate notice, with the fields enclosed by brackets "{}"
+ replaced with your own identifying information. (Don't include
+ the brackets!) The text should be enclosed in the appropriate
+ comment syntax for the file format. We also recommend that a
+ file or class name and description of purpose be included on the
+ same "printed page" as the copyright notice for easier
+ identification within third-party archives.
+
+ Copyright {yyyy} {name of copyright owner}
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
diff --git a/third-party/gopkg.in/yaml.v2/NOTICE b/third-party/gopkg.in/yaml.v2/NOTICE
new file mode 100644
index 000000000..866d74a7a
--- /dev/null
+++ b/third-party/gopkg.in/yaml.v2/NOTICE
@@ -0,0 +1,13 @@
+Copyright 2011-2016 Canonical Ltd.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.